@open-mercato/ai-assistant 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

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 (109) 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 +106 -0
  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/moderation-flags/AiModerationFlagsPageClient.js +139 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/data/entities.js +42 -0
  26. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  27. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
  28. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  29. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
  30. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
  31. package/dist/modules/ai_assistant/di.js +6 -2
  32. package/dist/modules/ai_assistant/di.js.map +2 -2
  33. package/dist/modules/ai_assistant/events.js +6 -0
  34. package/dist/modules/ai_assistant/events.js.map +2 -2
  35. package/dist/modules/ai_assistant/i18n/de.json +26 -0
  36. package/dist/modules/ai_assistant/i18n/en.json +26 -0
  37. package/dist/modules/ai_assistant/i18n/es.json +26 -0
  38. package/dist/modules/ai_assistant/i18n/pl.json +26 -0
  39. package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
  40. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  41. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  42. package/dist/modules/ai_assistant/lib/auth.js +6 -7
  43. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  44. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
  45. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  46. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
  47. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  48. package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
  49. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  50. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
  51. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
  52. package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
  53. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
  54. package/dist/modules/ai_assistant/lib/moderation.js +109 -0
  55. package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
  56. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
  57. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
  59. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  60. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
  61. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
  62. package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
  63. package/generated/entities/ai_moderation_flag/index.ts +9 -0
  64. package/generated/entities.ids.generated.ts +2 -1
  65. package/generated/entity-fields-registry.ts +13 -1
  66. package/package.json +6 -6
  67. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
  68. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
  69. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
  70. package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
  71. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
  72. package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
  73. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
  74. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
  75. package/src/modules/ai_assistant/api/settings/route.ts +33 -10
  76. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +146 -0
  77. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
  78. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
  79. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
  80. package/src/modules/ai_assistant/data/entities.ts +56 -0
  81. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
  82. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
  83. package/src/modules/ai_assistant/di.ts +5 -1
  84. package/src/modules/ai_assistant/events.ts +21 -0
  85. package/src/modules/ai_assistant/i18n/de.json +26 -0
  86. package/src/modules/ai_assistant/i18n/en.json +26 -0
  87. package/src/modules/ai_assistant/i18n/es.json +26 -0
  88. package/src/modules/ai_assistant/i18n/pl.json +26 -0
  89. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
  90. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
  91. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
  92. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
  93. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
  94. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
  95. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
  96. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
  97. package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
  98. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
  99. package/src/modules/ai_assistant/lib/auth.ts +6 -15
  100. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
  101. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
  102. package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
  103. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
  104. package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
  105. package/src/modules/ai_assistant/lib/moderation.ts +179 -0
  106. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
  107. package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
  108. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
  109. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/lib/moderation-flag-recorder.ts"],
4
+ "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { AiModerationFlagRepository } from '../data/repositories/AiModerationFlagRepository'\nimport { emitAiAssistantEvent } from '../events'\nimport type { ModerationCategoryResult } from './moderation'\n\nconst logger = createLogger('ai_assistant')\n\nexport interface RecordModerationFlagInput {\n tenantId: string | null\n organizationId: string | null\n agentId: string\n userId: string\n providerId: string\n modelId: string\n categories: Record<string, ModerationCategoryResult>\n}\n\n/**\n * Persists one `ai_moderation_flags` audit row and emits\n * `ai_assistant.moderation_flag.created`, best-effort.\n *\n * SAFETY CONTRACT: this MUST NEVER throw \u2014 the moderation rejection is the\n * primary effect and is thrown by the gate regardless. A missing tenant scope\n * (e.g. a system-scope caller) means the row cannot be tenant-scoped, so the\n * audit write is skipped. Any persistence/emit failure is logged and\n * swallowed so the user still receives the rejection.\n *\n * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.\n */\nexport async function recordModerationFlag(\n input: RecordModerationFlagInput,\n container: AwilixContainer | undefined,\n): Promise<void> {\n if (!container) return\n if (!input.tenantId) return\n\n let createdId: string | null = null\n try {\n const em = container.resolve<EntityManager>('em')\n const repo = new AiModerationFlagRepository(em.fork())\n const flag = await repo.create({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n agentId: input.agentId,\n userId: input.userId,\n providerId: input.providerId,\n modelId: input.modelId,\n categories: input.categories,\n })\n createdId = flag.id\n } catch (error) {\n logger.error('Failed to persist moderation flag (rejection still applies)', { err: error })\n return\n }\n\n // Emit AFTER a successful write, detached so a failed emit never retroactively\n // fails the audit insert.\n try {\n const flaggedCategories = Object.entries(input.categories)\n .filter(([, value]) => value.flagged)\n .map(([name]) => name)\n await emitAiAssistantEvent('ai_assistant.moderation_flag.created', {\n id: createdId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n agentId: input.agentId,\n userId: input.userId,\n categories: flaggedCategories,\n })\n } catch (error) {\n logger.error('Failed to emit moderation_flag.created (audit row persisted)', { err: error })\n }\n}\n"],
5
+ "mappings": "AAEA,SAAS,oBAAoB;AAC7B,SAAS,kCAAkC;AAC3C,SAAS,4BAA4B;AAGrC,MAAM,SAAS,aAAa,cAAc;AAwB1C,eAAsB,qBACpB,OACA,WACe;AACf,MAAI,CAAC,UAAW;AAChB,MAAI,CAAC,MAAM,SAAU;AAErB,MAAI,YAA2B;AAC/B,MAAI;AACF,UAAM,KAAK,UAAU,QAAuB,IAAI;AAChD,UAAM,OAAO,IAAI,2BAA2B,GAAG,KAAK,CAAC;AACrD,UAAM,OAAO,MAAM,KAAK,OAAO;AAAA,MAC7B,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,SAAS,MAAM;AAAA,MACf,QAAQ,MAAM;AAAA,MACd,YAAY,MAAM;AAAA,MAClB,SAAS,MAAM;AAAA,MACf,YAAY,MAAM;AAAA,IACpB,CAAC;AACD,gBAAY,KAAK;AAAA,EACnB,SAAS,OAAO;AACd,WAAO,MAAM,+DAA+D,EAAE,KAAK,MAAM,CAAC;AAC1F;AAAA,EACF;AAIA,MAAI;AACF,UAAM,oBAAoB,OAAO,QAAQ,MAAM,UAAU,EACtD,OAAO,CAAC,CAAC,EAAE,KAAK,MAAM,MAAM,OAAO,EACnC,IAAI,CAAC,CAAC,IAAI,MAAM,IAAI;AACvB,UAAM,qBAAqB,wCAAwC;AAAA,MACjE,IAAI;AAAA,MACJ,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,SAAS,MAAM;AAAA,MACf,QAAQ,MAAM;AAAA,MACd,YAAY;AAAA,IACd,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,gEAAgE,EAAE,KAAK,MAAM,CAAC;AAAA,EAC7F;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,24 @@
1
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
+ function resolveModerationPolicy(input) {
3
+ if (input.untrustedInput === true) return "enforced";
4
+ if (input.perAgentOverride !== null && input.perAgentOverride !== void 0) {
5
+ return input.perAgentOverride ? "on" : "off";
6
+ }
7
+ if (input.tenantWideOverride !== null && input.tenantWideOverride !== void 0) {
8
+ return input.tenantWideOverride ? "on" : "off";
9
+ }
10
+ const env = input.env ?? process.env;
11
+ return parseBooleanWithDefault(env.OM_AI_INPUT_MODERATION, false) ? "on" : "off";
12
+ }
13
+ function isModerationActive(policy) {
14
+ return policy === "enforced" || policy === "on";
15
+ }
16
+ function shouldFailClosed(policy) {
17
+ return policy === "enforced";
18
+ }
19
+ export {
20
+ isModerationActive,
21
+ resolveModerationPolicy,
22
+ shouldFailClosed
23
+ };
24
+ //# sourceMappingURL=moderation-policy.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/lib/moderation-policy.ts"],
4
+ "sourcesContent": ["/**\n * Input-moderation policy resolution.\n *\n * Resolves the effective moderation policy for an agent turn using a fixed\n * precedence (first match wins):\n *\n * 1. `agentDef.untrustedInput === true` \u2192 `enforced` (tenant cannot disable).\n * 2. Per-agent tenant override (`ai_agent_runtime_overrides` row with an\n * `agent_id`) `input_moderation` \u2192 `on` / `off`.\n * 3. Tenant-wide override (row with `agent_id = NULL`) `input_moderation` \u2192\n * `on` / `off`.\n * 4. Env default `OM_AI_INPUT_MODERATION` (parsed via `parseBooleanWithDefault`)\n * \u2192 `on` / `off`.\n * 5. Default `off`.\n *\n * Steps 4 and 5 collapse into `parseBooleanWithDefault(env, false)`.\n *\n * @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md\n */\n\nimport type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\n/** Effective runtime moderation policy for a turn. */\nexport type ModerationPolicy = 'enforced' | 'on' | 'off'\n\n/** The per-agent display state shown in settings (adds `inherit`). */\nexport type ModerationPolicyDisplay = 'enforced' | 'on' | 'off' | 'inherit'\n\nexport interface ResolveModerationPolicyInput {\n /** Whether the agent definition marks this surface as untrusted. */\n untrustedInput?: boolean\n /**\n * `input_moderation` from the agent-scoped tenant override row, or\n * `null`/`undefined` when the row is absent or leaves it unset (inherit).\n */\n perAgentOverride?: boolean | null\n /**\n * `input_moderation` from the tenant-wide override row (`agent_id = NULL`),\n * or `null`/`undefined` when absent/unset (inherit).\n */\n tenantWideOverride?: boolean | null\n /** Env lookup; reads `OM_AI_INPUT_MODERATION`. Defaults to `process.env`. */\n env?: EnvLookup\n}\n\n/**\n * Resolves the effective moderation policy following the 5-step precedence.\n * Pure and synchronous \u2014 the runtime hydrates the override values from the DB\n * before calling this.\n */\nexport function resolveModerationPolicy(input: ResolveModerationPolicyInput): ModerationPolicy {\n if (input.untrustedInput === true) return 'enforced'\n if (input.perAgentOverride !== null && input.perAgentOverride !== undefined) {\n return input.perAgentOverride ? 'on' : 'off'\n }\n if (input.tenantWideOverride !== null && input.tenantWideOverride !== undefined) {\n return input.tenantWideOverride ? 'on' : 'off'\n }\n const env = input.env ?? process.env\n return parseBooleanWithDefault(env.OM_AI_INPUT_MODERATION, false) ? 'on' : 'off'\n}\n\n/** Whether a resolved policy should run the moderation gate for the turn. */\nexport function isModerationActive(policy: ModerationPolicy): boolean {\n return policy === 'enforced' || policy === 'on'\n}\n\n/**\n * Whether an *unavailable* moderation check (endpoint outage) should fail\n * closed (reject the turn) rather than fail open (allow it through). Per spec:\n * only `enforced` (untrusted) surfaces fail closed; opt-in `on` surfaces prefer\n * availability and fail open. Both paths log. (`off` never reaches the gate.)\n */\nexport function shouldFailClosed(policy: ModerationPolicy): boolean {\n return policy === 'enforced'\n}\n"],
5
+ "mappings": "AAqBA,SAAS,+BAA+B;AA8BjC,SAAS,wBAAwB,OAAuD;AAC7F,MAAI,MAAM,mBAAmB,KAAM,QAAO;AAC1C,MAAI,MAAM,qBAAqB,QAAQ,MAAM,qBAAqB,QAAW;AAC3E,WAAO,MAAM,mBAAmB,OAAO;AAAA,EACzC;AACA,MAAI,MAAM,uBAAuB,QAAQ,MAAM,uBAAuB,QAAW;AAC/E,WAAO,MAAM,qBAAqB,OAAO;AAAA,EAC3C;AACA,QAAM,MAAM,MAAM,OAAO,QAAQ;AACjC,SAAO,wBAAwB,IAAI,wBAAwB,KAAK,IAAI,OAAO;AAC7E;AAGO,SAAS,mBAAmB,QAAmC;AACpE,SAAO,WAAW,cAAc,WAAW;AAC7C;AAQO,SAAS,iBAAiB,QAAmC;AAClE,SAAO,WAAW;AACpB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ const DEFAULT_MODERATION_MODEL = "omni-moderation-latest";
3
+ const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
4
+ const REQUEST_TIMEOUT_MS = 3e3;
5
+ class AiModerationBlockedError extends Error {
6
+ constructor(categories) {
7
+ super("[internal] Input rejected by the content safety filter");
8
+ this.name = "AiModerationBlockedError";
9
+ this.categories = categories;
10
+ }
11
+ }
12
+ class AiModerationUnavailableError extends Error {
13
+ constructor(message, cause) {
14
+ super(`[internal] ${message}`);
15
+ this.name = "AiModerationUnavailableError";
16
+ this.cause = cause;
17
+ }
18
+ }
19
+ const moderationResponseSchema = z.object({
20
+ results: z.array(
21
+ z.object({
22
+ flagged: z.boolean(),
23
+ categories: z.record(z.string(), z.boolean()),
24
+ category_scores: z.record(z.string(), z.number())
25
+ })
26
+ ).min(1)
27
+ });
28
+ function mergeCategories(categories, scores) {
29
+ const merged = {};
30
+ for (const [name, flagged] of Object.entries(categories)) {
31
+ merged[name] = { flagged, score: scores[name] ?? 0 };
32
+ }
33
+ return merged;
34
+ }
35
+ async function postModeration(input) {
36
+ const baseURL = (input.baseURL ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "");
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
39
+ try {
40
+ return await fetch(`${baseURL}/moderations`, {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ Authorization: `Bearer ${input.apiKey}`
45
+ },
46
+ body: JSON.stringify({
47
+ model: input.model ?? DEFAULT_MODERATION_MODEL,
48
+ input: input.text
49
+ }),
50
+ signal: controller.signal
51
+ });
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+ function createModerationService() {
57
+ return {
58
+ async checkInput(input) {
59
+ let lastError;
60
+ for (let attempt = 0; attempt < 2; attempt += 1) {
61
+ let response;
62
+ try {
63
+ response = await postModeration(input);
64
+ } catch (error) {
65
+ lastError = error;
66
+ continue;
67
+ }
68
+ if (response.status >= 500) {
69
+ lastError = new Error(`moderation endpoint returned ${response.status}`);
70
+ continue;
71
+ }
72
+ if (!response.ok) {
73
+ throw new AiModerationUnavailableError(
74
+ `moderation endpoint returned ${response.status}`
75
+ );
76
+ }
77
+ let json;
78
+ try {
79
+ json = await response.json();
80
+ } catch (error) {
81
+ throw new AiModerationUnavailableError("moderation response was not valid JSON", error);
82
+ }
83
+ const parsed = moderationResponseSchema.safeParse(json);
84
+ if (!parsed.success) {
85
+ throw new AiModerationUnavailableError(
86
+ "moderation response did not match the expected schema",
87
+ parsed.error
88
+ );
89
+ }
90
+ const result = parsed.data.results[0];
91
+ return {
92
+ flagged: result.flagged,
93
+ categories: mergeCategories(result.categories, result.category_scores)
94
+ };
95
+ }
96
+ throw new AiModerationUnavailableError(
97
+ "moderation endpoint unreachable after retry",
98
+ lastError
99
+ );
100
+ }
101
+ };
102
+ }
103
+ export {
104
+ AiModerationBlockedError,
105
+ AiModerationUnavailableError,
106
+ DEFAULT_MODERATION_MODEL,
107
+ createModerationService
108
+ };
109
+ //# sourceMappingURL=moderation.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/lib/moderation.ts"],
4
+ "sourcesContent": ["/**\n * Input pre-moderation service.\n *\n * Wraps OpenAI's free `/v1/moderations` endpoint so the agent runtime can\n * screen the current user turn BEFORE the model call on surfaces that opt in\n * (or are forced on via `untrustedInput`). The service is intentionally pure:\n * it performs the HTTP check and returns category flags + scores, or throws\n * {@link AiModerationUnavailableError} when the endpoint is unreachable. It does\n * NOT persist audit records, emit events, or throw the user-facing block \u2014 the\n * runtime gate orchestrates those (see `agent-runtime.ts`).\n *\n * @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md\n */\n\nimport { z } from 'zod'\n\n/** Default moderation model; override with `OM_AI_MODERATION_MODEL`. */\nexport const DEFAULT_MODERATION_MODEL = 'omni-moderation-latest'\n\nconst DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'\nconst REQUEST_TIMEOUT_MS = 3000\n\n/** Per-category moderation outcome: whether it tripped and the model's score. */\nexport interface ModerationCategoryResult {\n flagged: boolean\n score: number\n}\n\n/** Result of an input moderation check \u2014 flags + scores only, never content. */\nexport interface ModerationResult {\n flagged: boolean\n categories: Record<string, ModerationCategoryResult>\n}\n\n/** Arguments for {@link ModerationService.checkInput}. */\nexport interface ModerationCheckInput {\n /** The current user turn text to screen. */\n text: string\n /** Resolved OpenAI(-compatible) API key for the moderations endpoint. */\n apiKey: string\n /** Optional base URL override (defaults to the OpenAI public endpoint). */\n baseURL?: string\n /** Optional model override (defaults to {@link DEFAULT_MODERATION_MODEL}). */\n model?: string\n}\n\n/** Port resolved from the DI container by the agent runtime. */\nexport interface ModerationService {\n checkInput(input: ModerationCheckInput): Promise<ModerationResult>\n}\n\n/**\n * Thrown by the runtime gate (NOT the service) when flagged input is rejected\n * on an enabled/enforced surface. The SSE error path maps it to the\n * `moderation_blocked` code; the raw categories are never sent to the client.\n */\nexport class AiModerationBlockedError extends Error {\n readonly categories: Record<string, ModerationCategoryResult>\n\n constructor(categories: Record<string, ModerationCategoryResult>) {\n super('[internal] Input rejected by the content safety filter')\n this.name = 'AiModerationBlockedError'\n this.categories = categories\n }\n}\n\n/**\n * Thrown by {@link ModerationService.checkInput} when the moderation endpoint\n * is unreachable (timeout, network error, or non-2xx after one retry). The gate\n * decides fail-closed (enforced surfaces) vs fail-open (opt-in surfaces).\n */\nexport class AiModerationUnavailableError extends Error {\n readonly cause?: unknown\n\n constructor(message: string, cause?: unknown) {\n super(`[internal] ${message}`)\n this.name = 'AiModerationUnavailableError'\n this.cause = cause\n }\n}\n\nconst moderationResponseSchema = z.object({\n results: z\n .array(\n z.object({\n flagged: z.boolean(),\n categories: z.record(z.string(), z.boolean()),\n category_scores: z.record(z.string(), z.number()),\n }),\n )\n .min(1),\n})\n\nfunction mergeCategories(\n categories: Record<string, boolean>,\n scores: Record<string, number>,\n): Record<string, ModerationCategoryResult> {\n const merged: Record<string, ModerationCategoryResult> = {}\n for (const [name, flagged] of Object.entries(categories)) {\n merged[name] = { flagged, score: scores[name] ?? 0 }\n }\n return merged\n}\n\nasync function postModeration(input: ModerationCheckInput): Promise<Response> {\n const baseURL = (input.baseURL ?? DEFAULT_OPENAI_BASE_URL).replace(/\\/$/, '')\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n return await fetch(`${baseURL}/moderations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${input.apiKey}`,\n },\n body: JSON.stringify({\n model: input.model ?? DEFAULT_MODERATION_MODEL,\n input: input.text,\n }),\n signal: controller.signal,\n })\n } finally {\n clearTimeout(timer)\n }\n}\n\n/**\n * Builds the default {@link ModerationService}. Stateless and DI-friendly \u2014\n * registered in `di.ts` and overridable by downstream apps.\n */\nexport function createModerationService(): ModerationService {\n return {\n async checkInput(input: ModerationCheckInput): Promise<ModerationResult> {\n let lastError: unknown\n // One short-timeout attempt + one retry (R: outage mitigation).\n for (let attempt = 0; attempt < 2; attempt += 1) {\n let response: Response\n try {\n response = await postModeration(input)\n } catch (error) {\n lastError = error\n continue\n }\n if (response.status >= 500) {\n lastError = new Error(`moderation endpoint returned ${response.status}`)\n continue\n }\n if (!response.ok) {\n // 4xx is a configuration/credential error \u2014 not transient; do not retry.\n throw new AiModerationUnavailableError(\n `moderation endpoint returned ${response.status}`,\n )\n }\n let json: unknown\n try {\n json = await response.json()\n } catch (error) {\n throw new AiModerationUnavailableError('moderation response was not valid JSON', error)\n }\n const parsed = moderationResponseSchema.safeParse(json)\n if (!parsed.success) {\n throw new AiModerationUnavailableError(\n 'moderation response did not match the expected schema',\n parsed.error,\n )\n }\n const result = parsed.data.results[0]\n return {\n flagged: result.flagged,\n categories: mergeCategories(result.categories, result.category_scores),\n }\n }\n throw new AiModerationUnavailableError(\n 'moderation endpoint unreachable after retry',\n lastError,\n )\n },\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -4,6 +4,8 @@ const OPENAI_PRESET = {
4
4
  baseURL: void 0,
5
5
  baseURLEnvKeys: ["OPENAI_BASE_URL"],
6
6
  envKeys: ["OPENAI_API_KEY", "OPENCODE_OPENAI_API_KEY"],
7
+ supportsInputModeration: true,
8
+ supportsEndUserSafetyIdentifier: true,
7
9
  defaultModel: "gpt-5-mini",
8
10
  defaultModels: [
9
11
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/openai-compatible-presets.ts"],
4
- "sourcesContent": ["/**\n * Curated registry of OpenAI-compatible LLM backends.\n *\n * Each preset is plain data \u2014 adding a new backend takes one entry in\n * this array, zero new adapter files, and zero changes to route handlers.\n * The {@link createOpenAICompatibleProvider} factory in `./llm-adapters/openai.ts`\n * turns each preset into a concrete `LlmProvider` at bootstrap time.\n *\n * Preset model catalogs are curated snapshots as of 2026-04-14 and should\n * be updated as upstream catalogs evolve. Users can always override the\n * selected model via the `OPENCODE_MODEL` env var without editing this\n * file.\n *\n * @see ./llm-adapters/openai.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport type { OpenAICompatiblePreset } from './llm-adapters/openai'\n\n/**\n * Standard OpenAI \u2014 default OpenAI API at api.openai.com.\n */\nconst OPENAI_PRESET: OpenAICompatiblePreset = {\n id: 'openai',\n name: 'OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['OPENAI_BASE_URL'],\n envKeys: ['OPENAI_API_KEY', 'OPENCODE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n tags: ['flagship'],\n },\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-4o',\n name: 'GPT-4o',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * DeepInfra \u2014 hosts open-weight flagship models at 3-12\u00D7 lower cost than\n * the native APIs. The curated catalog targets the AI Assistant use case\n * (routing + tool use + conversational chat).\n */\nconst DEEPINFRA_PRESET: OpenAICompatiblePreset = {\n id: 'deepinfra',\n name: 'DeepInfra',\n baseURL: 'https://api.deepinfra.com/v1/openai',\n baseURLEnvKeys: ['DEEPINFRA_BASE_URL'],\n envKeys: ['DEEPINFRA_API_KEY'],\n defaultModel: 'zai-org/GLM-5.1',\n defaultModels: [\n {\n id: 'zai-org/GLM-5.1',\n name: 'GLM-5.1 (Zhipu)',\n contextWindow: 202752,\n tags: ['flagship'],\n },\n {\n id: 'zai-org/GLM-4.7-Flash',\n name: 'GLM-4.7 Flash',\n contextWindow: 202752,\n tags: ['budget'],\n },\n {\n id: 'Qwen/Qwen3-235B-A22B-Instruct-2507',\n name: 'Qwen3 235B (MoE)',\n contextWindow: 262144,\n tags: ['flagship'],\n },\n {\n id: 'meta-llama/Llama-4-Scout-17B-16E-Instruct',\n name: 'Llama 4 Scout',\n contextWindow: 327680,\n },\n {\n id: 'deepseek-ai/DeepSeek-V3.2-Exp',\n name: 'DeepSeek V3.2',\n contextWindow: 163840,\n tags: ['reasoning'],\n },\n {\n id: 'Qwen/Qwen3-Coder-30B-A3B-Instruct',\n name: 'Qwen3 Coder 30B',\n contextWindow: 262144,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * Groq \u2014 specializes in low-latency inference on LPU hardware.\n * Best suited for snappy tool-use and routing, less so for long reasoning.\n */\nconst GROQ_PRESET: OpenAICompatiblePreset = {\n id: 'groq',\n name: 'Groq',\n baseURL: 'https://api.groq.com/openai/v1',\n baseURLEnvKeys: ['GROQ_BASE_URL'],\n envKeys: ['GROQ_API_KEY'],\n defaultModel: 'llama-3.3-70b-versatile',\n defaultModels: [\n {\n id: 'llama-3.3-70b-versatile',\n name: 'Llama 3.3 70B Versatile',\n contextWindow: 131072,\n },\n {\n id: 'llama-4-scout-17b',\n name: 'Llama 4 Scout 17B',\n contextWindow: 131072,\n },\n {\n id: 'mixtral-8x22b-32768',\n name: 'Mixtral 8x22B',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Together AI \u2014 broad catalog of open-weight models with per-model pricing.\n */\nconst TOGETHER_PRESET: OpenAICompatiblePreset = {\n id: 'together',\n name: 'Together AI',\n baseURL: 'https://api.together.xyz/v1',\n baseURLEnvKeys: ['TOGETHER_BASE_URL'],\n envKeys: ['TOGETHER_API_KEY'],\n defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n defaultModels: [\n {\n id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n name: 'Llama 3.3 70B Turbo',\n contextWindow: 131072,\n },\n {\n id: 'Qwen/Qwen2.5-72B-Instruct-Turbo',\n name: 'Qwen 2.5 72B Turbo',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Fireworks AI \u2014 fast inference with a curated catalog.\n */\nconst FIREWORKS_PRESET: OpenAICompatiblePreset = {\n id: 'fireworks',\n name: 'Fireworks AI',\n baseURL: 'https://api.fireworks.ai/inference/v1',\n baseURLEnvKeys: ['FIREWORKS_BASE_URL'],\n envKeys: ['FIREWORKS_API_KEY'],\n defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n defaultModels: [\n {\n id: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n name: 'Llama 3.3 70B',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * Azure OpenAI \u2014 enterprise Azure deployments. Base URL is deployment-\n * specific and must be provided via `AZURE_OPENAI_BASE_URL`.\n */\nconst AZURE_PRESET: OpenAICompatiblePreset = {\n id: 'azure',\n name: 'Azure OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['AZURE_OPENAI_BASE_URL'],\n envKeys: ['AZURE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * LiteLLM proxy \u2014 self-hosted router for arbitrary upstream providers.\n * Base URL must be supplied via `LITELLM_BASE_URL`.\n */\nconst LITELLM_PRESET: OpenAICompatiblePreset = {\n id: 'litellm',\n name: 'LiteLLM',\n baseURL: 'http://localhost:4000/v1',\n baseURLEnvKeys: ['LITELLM_BASE_URL'],\n envKeys: ['LITELLM_API_KEY'],\n defaultModel: 'gpt-4o-mini',\n // LiteLLM proxies arbitrary upstreams and commonly relays `vendor/model` ids\n // (e.g. `anthropic/claude-\u2026`), so a configured selection must not slash-split.\n usesVendorPrefixedModelIds: true,\n defaultModels: [\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini (via LiteLLM)',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * Ollama \u2014 local model runner for development and offline use.\n * Default port 11434 can be overridden via `OLLAMA_BASE_URL`.\n */\nconst OLLAMA_PRESET: OpenAICompatiblePreset = {\n id: 'ollama',\n name: 'Ollama (local)',\n baseURL: 'http://localhost:11434/v1',\n baseURLEnvKeys: ['OLLAMA_BASE_URL'],\n envKeys: ['OLLAMA_API_KEY'],\n defaultModel: 'llama3.3',\n defaultModels: [\n {\n id: 'llama3.3',\n name: 'Llama 3.3 (local)',\n contextWindow: 131072,\n },\n {\n id: 'qwen2.5-coder',\n name: 'Qwen 2.5 Coder (local)',\n contextWindow: 131072,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * OpenRouter \u2014 unified API gateway providing access to hundreds of models\n * from Anthropic, OpenAI, Google, Meta, and others via a single OpenAI-\n * compatible endpoint.\n */\nconst OPENROUTER_PRESET: OpenAICompatiblePreset = {\n id: 'openrouter',\n name: 'OpenRouter',\n baseURL: 'https://openrouter.ai/api/v1',\n baseURLEnvKeys: ['OPENROUTER_BASE_URL'],\n envKeys: ['OPENROUTER_API_KEY'],\n defaultModel: 'meta-llama/llama-3.3-70b-instruct',\n // OpenRouter model ids are `vendor/model` (e.g. `anthropic/claude-sonnet-4.5`);\n // the leading `anthropic/` is part of the model id, not a native-provider pin.\n usesVendorPrefixedModelIds: true,\n defaultModels: [\n {\n id: 'meta-llama/llama-3.3-70b-instruct',\n name: 'Llama 3.3 70B Instruct',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * Requesty \u2014 unified OpenAI-compatible LLM gateway providing access to\n * models from many providers via a single endpoint, using `provider/model`\n * naming (e.g. `openai/gpt-4o-mini`).\n */\nconst REQUESTY_PRESET: OpenAICompatiblePreset = {\n id: 'requesty',\n name: 'Requesty',\n baseURL: 'https://router.requesty.ai/v1',\n baseURLEnvKeys: ['REQUESTY_BASE_URL'],\n envKeys: ['REQUESTY_API_KEY'],\n defaultModel: 'openai/gpt-4o-mini',\n // Requesty uses `provider/model` naming (e.g. `openai/gpt-4o-mini`) natively.\n usesVendorPrefixedModelIds: true,\n defaultModels: [\n {\n id: 'openai/gpt-4o-mini',\n name: 'GPT-4o mini',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * LM Studio \u2014 local model server for development and offline use.\n * Default port 1234 can be overridden via `LM_STUDIO_BASE_URL`.\n * `defaultModel` is intentionally empty \u2014 LM Studio auto-detects\n * the loaded model when the request body's `model` field is empty.\n */\nconst LM_STUDIO_PRESET: OpenAICompatiblePreset = {\n id: 'lm-studio',\n name: 'LM Studio (local)',\n baseURL: 'http://localhost:1234/v1',\n baseURLEnvKeys: ['LM_STUDIO_BASE_URL'],\n envKeys: ['LM_STUDIO_API_KEY'],\n defaultModel: '',\n defaultModels: [],\n}\n\n/**\n * Built-in presets registered at bootstrap time. Order matters \u2014 it\n * determines the default iteration order of\n * `llmProviderRegistry.resolveFirstConfigured()` when no explicit order\n * is supplied.\n */\nexport const OPENAI_COMPATIBLE_PRESETS: readonly OpenAICompatiblePreset[] = [\n OPENAI_PRESET,\n DEEPINFRA_PRESET,\n GROQ_PRESET,\n TOGETHER_PRESET,\n FIREWORKS_PRESET,\n AZURE_PRESET,\n LITELLM_PRESET,\n OLLAMA_PRESET,\n OPENROUTER_PRESET,\n REQUESTY_PRESET,\n LM_STUDIO_PRESET,\n]\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Curated registry of OpenAI-compatible LLM backends.\n *\n * Each preset is plain data \u2014 adding a new backend takes one entry in\n * this array, zero new adapter files, and zero changes to route handlers.\n * The {@link createOpenAICompatibleProvider} factory in `./llm-adapters/openai.ts`\n * turns each preset into a concrete `LlmProvider` at bootstrap time.\n *\n * Preset model catalogs are curated snapshots as of 2026-04-14 and should\n * be updated as upstream catalogs evolve. Users can always override the\n * selected model via the `OPENCODE_MODEL` env var without editing this\n * file.\n *\n * @see ./llm-adapters/openai.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport type { OpenAICompatiblePreset } from './llm-adapters/openai'\n\n/**\n * Standard OpenAI \u2014 default OpenAI API at api.openai.com.\n */\nconst OPENAI_PRESET: OpenAICompatiblePreset = {\n id: 'openai',\n name: 'OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['OPENAI_BASE_URL'],\n envKeys: ['OPENAI_API_KEY', 'OPENCODE_OPENAI_API_KEY'],\n supportsInputModeration: true,\n supportsEndUserSafetyIdentifier: true,\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n tags: ['flagship'],\n },\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-4o',\n name: 'GPT-4o',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * DeepInfra \u2014 hosts open-weight flagship models at 3-12\u00D7 lower cost than\n * the native APIs. The curated catalog targets the AI Assistant use case\n * (routing + tool use + conversational chat).\n */\nconst DEEPINFRA_PRESET: OpenAICompatiblePreset = {\n id: 'deepinfra',\n name: 'DeepInfra',\n baseURL: 'https://api.deepinfra.com/v1/openai',\n baseURLEnvKeys: ['DEEPINFRA_BASE_URL'],\n envKeys: ['DEEPINFRA_API_KEY'],\n defaultModel: 'zai-org/GLM-5.1',\n defaultModels: [\n {\n id: 'zai-org/GLM-5.1',\n name: 'GLM-5.1 (Zhipu)',\n contextWindow: 202752,\n tags: ['flagship'],\n },\n {\n id: 'zai-org/GLM-4.7-Flash',\n name: 'GLM-4.7 Flash',\n contextWindow: 202752,\n tags: ['budget'],\n },\n {\n id: 'Qwen/Qwen3-235B-A22B-Instruct-2507',\n name: 'Qwen3 235B (MoE)',\n contextWindow: 262144,\n tags: ['flagship'],\n },\n {\n id: 'meta-llama/Llama-4-Scout-17B-16E-Instruct',\n name: 'Llama 4 Scout',\n contextWindow: 327680,\n },\n {\n id: 'deepseek-ai/DeepSeek-V3.2-Exp',\n name: 'DeepSeek V3.2',\n contextWindow: 163840,\n tags: ['reasoning'],\n },\n {\n id: 'Qwen/Qwen3-Coder-30B-A3B-Instruct',\n name: 'Qwen3 Coder 30B',\n contextWindow: 262144,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * Groq \u2014 specializes in low-latency inference on LPU hardware.\n * Best suited for snappy tool-use and routing, less so for long reasoning.\n */\nconst GROQ_PRESET: OpenAICompatiblePreset = {\n id: 'groq',\n name: 'Groq',\n baseURL: 'https://api.groq.com/openai/v1',\n baseURLEnvKeys: ['GROQ_BASE_URL'],\n envKeys: ['GROQ_API_KEY'],\n defaultModel: 'llama-3.3-70b-versatile',\n defaultModels: [\n {\n id: 'llama-3.3-70b-versatile',\n name: 'Llama 3.3 70B Versatile',\n contextWindow: 131072,\n },\n {\n id: 'llama-4-scout-17b',\n name: 'Llama 4 Scout 17B',\n contextWindow: 131072,\n },\n {\n id: 'mixtral-8x22b-32768',\n name: 'Mixtral 8x22B',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Together AI \u2014 broad catalog of open-weight models with per-model pricing.\n */\nconst TOGETHER_PRESET: OpenAICompatiblePreset = {\n id: 'together',\n name: 'Together AI',\n baseURL: 'https://api.together.xyz/v1',\n baseURLEnvKeys: ['TOGETHER_BASE_URL'],\n envKeys: ['TOGETHER_API_KEY'],\n defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n defaultModels: [\n {\n id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n name: 'Llama 3.3 70B Turbo',\n contextWindow: 131072,\n },\n {\n id: 'Qwen/Qwen2.5-72B-Instruct-Turbo',\n name: 'Qwen 2.5 72B Turbo',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Fireworks AI \u2014 fast inference with a curated catalog.\n */\nconst FIREWORKS_PRESET: OpenAICompatiblePreset = {\n id: 'fireworks',\n name: 'Fireworks AI',\n baseURL: 'https://api.fireworks.ai/inference/v1',\n baseURLEnvKeys: ['FIREWORKS_BASE_URL'],\n envKeys: ['FIREWORKS_API_KEY'],\n defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n defaultModels: [\n {\n id: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n name: 'Llama 3.3 70B',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * Azure OpenAI \u2014 enterprise Azure deployments. Base URL is deployment-\n * specific and must be provided via `AZURE_OPENAI_BASE_URL`.\n */\nconst AZURE_PRESET: OpenAICompatiblePreset = {\n id: 'azure',\n name: 'Azure OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['AZURE_OPENAI_BASE_URL'],\n envKeys: ['AZURE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * LiteLLM proxy \u2014 self-hosted router for arbitrary upstream providers.\n * Base URL must be supplied via `LITELLM_BASE_URL`.\n */\nconst LITELLM_PRESET: OpenAICompatiblePreset = {\n id: 'litellm',\n name: 'LiteLLM',\n baseURL: 'http://localhost:4000/v1',\n baseURLEnvKeys: ['LITELLM_BASE_URL'],\n envKeys: ['LITELLM_API_KEY'],\n defaultModel: 'gpt-4o-mini',\n // LiteLLM proxies arbitrary upstreams and commonly relays `vendor/model` ids\n // (e.g. `anthropic/claude-\u2026`), so a configured selection must not slash-split.\n usesVendorPrefixedModelIds: true,\n defaultModels: [\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini (via LiteLLM)',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * Ollama \u2014 local model runner for development and offline use.\n * Default port 11434 can be overridden via `OLLAMA_BASE_URL`.\n */\nconst OLLAMA_PRESET: OpenAICompatiblePreset = {\n id: 'ollama',\n name: 'Ollama (local)',\n baseURL: 'http://localhost:11434/v1',\n baseURLEnvKeys: ['OLLAMA_BASE_URL'],\n envKeys: ['OLLAMA_API_KEY'],\n defaultModel: 'llama3.3',\n defaultModels: [\n {\n id: 'llama3.3',\n name: 'Llama 3.3 (local)',\n contextWindow: 131072,\n },\n {\n id: 'qwen2.5-coder',\n name: 'Qwen 2.5 Coder (local)',\n contextWindow: 131072,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * OpenRouter \u2014 unified API gateway providing access to hundreds of models\n * from Anthropic, OpenAI, Google, Meta, and others via a single OpenAI-\n * compatible endpoint.\n */\nconst OPENROUTER_PRESET: OpenAICompatiblePreset = {\n id: 'openrouter',\n name: 'OpenRouter',\n baseURL: 'https://openrouter.ai/api/v1',\n baseURLEnvKeys: ['OPENROUTER_BASE_URL'],\n envKeys: ['OPENROUTER_API_KEY'],\n defaultModel: 'meta-llama/llama-3.3-70b-instruct',\n // OpenRouter model ids are `vendor/model` (e.g. `anthropic/claude-sonnet-4.5`);\n // the leading `anthropic/` is part of the model id, not a native-provider pin.\n usesVendorPrefixedModelIds: true,\n defaultModels: [\n {\n id: 'meta-llama/llama-3.3-70b-instruct',\n name: 'Llama 3.3 70B Instruct',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * Requesty \u2014 unified OpenAI-compatible LLM gateway providing access to\n * models from many providers via a single endpoint, using `provider/model`\n * naming (e.g. `openai/gpt-4o-mini`).\n */\nconst REQUESTY_PRESET: OpenAICompatiblePreset = {\n id: 'requesty',\n name: 'Requesty',\n baseURL: 'https://router.requesty.ai/v1',\n baseURLEnvKeys: ['REQUESTY_BASE_URL'],\n envKeys: ['REQUESTY_API_KEY'],\n defaultModel: 'openai/gpt-4o-mini',\n // Requesty uses `provider/model` naming (e.g. `openai/gpt-4o-mini`) natively.\n usesVendorPrefixedModelIds: true,\n defaultModels: [\n {\n id: 'openai/gpt-4o-mini',\n name: 'GPT-4o mini',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * LM Studio \u2014 local model server for development and offline use.\n * Default port 1234 can be overridden via `LM_STUDIO_BASE_URL`.\n * `defaultModel` is intentionally empty \u2014 LM Studio auto-detects\n * the loaded model when the request body's `model` field is empty.\n */\nconst LM_STUDIO_PRESET: OpenAICompatiblePreset = {\n id: 'lm-studio',\n name: 'LM Studio (local)',\n baseURL: 'http://localhost:1234/v1',\n baseURLEnvKeys: ['LM_STUDIO_BASE_URL'],\n envKeys: ['LM_STUDIO_API_KEY'],\n defaultModel: '',\n defaultModels: [],\n}\n\n/**\n * Built-in presets registered at bootstrap time. Order matters \u2014 it\n * determines the default iteration order of\n * `llmProviderRegistry.resolveFirstConfigured()` when no explicit order\n * is supplied.\n */\nexport const OPENAI_COMPATIBLE_PRESETS: readonly OpenAICompatiblePreset[] = [\n OPENAI_PRESET,\n DEEPINFRA_PRESET,\n GROQ_PRESET,\n TOGETHER_PRESET,\n FIREWORKS_PRESET,\n AZURE_PRESET,\n LITELLM_PRESET,\n OLLAMA_PRESET,\n OPENROUTER_PRESET,\n REQUESTY_PRESET,\n LM_STUDIO_PRESET,\n]\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
1
2
  import {
2
3
  TOOL_ENTITY_ID,
3
4
  GLOBAL_TENANT_ID,
@@ -177,18 +178,9 @@ class ToolSearchService {
177
178
  * Check if user has required features to access a tool.
178
179
  */
179
180
  hasFeatureAccess(requiredFeatures, userFeatures, isSuperAdmin) {
180
- if (isSuperAdmin) return true;
181
- if (!requiredFeatures?.length) return true;
182
- return requiredFeatures.every((required) => {
183
- if (userFeatures.includes(required)) return true;
184
- if (userFeatures.includes("*")) return true;
185
- return userFeatures.some((feature) => {
186
- if (feature.endsWith(".*")) {
187
- const prefix = feature.slice(0, -2);
188
- return required.startsWith(prefix + ".");
189
- }
190
- return false;
191
- });
181
+ return authorizeFeatures(requiredFeatures ?? [], {
182
+ grantedFeatures: userFeatures,
183
+ unrestricted: isSuperAdmin
192
184
  });
193
185
  }
194
186
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/tool-search.ts"],
4
- "sourcesContent": ["import type { SearchService } from '@open-mercato/search/service'\nimport type { SearchStrategyId, IndexableRecord } from '@open-mercato/search/types'\nimport type { McpToolRegistry, McpToolDefinition } from './types'\nimport {\n TOOL_ENTITY_ID,\n GLOBAL_TENANT_ID,\n TOOL_SEARCH_CONFIG,\n toolToIndexableRecord,\n computeToolsChecksum,\n} from './tool-index-config'\n\n/**\n * Result from tool search.\n */\nexport type ToolSearchResult = {\n /** Tool name (recordId) */\n toolName: string\n /** Relevance score */\n score: number\n /** Module that registered the tool */\n moduleId?: string\n /** Features required to use this tool */\n requiredFeatures?: string[]\n}\n\n/**\n * Status of available search strategies.\n */\nexport type StrategyStatus = {\n fulltext: boolean\n vector: boolean\n tokens: boolean\n /** Ordered list of available strategies */\n available: SearchStrategyId[]\n /** Strategies that will be used by default */\n default: SearchStrategyId[]\n}\n\n/**\n * Result from tool indexing operation.\n */\nexport type IndexingResult = {\n indexed: number\n skipped: number\n strategies: SearchStrategyId[]\n checksum: string\n}\n\n/**\n * Options for searching tools.\n */\nexport type ToolSearchOptions = {\n /** Maximum results to return (default: 12) */\n limit?: number\n /** Override strategies to use */\n strategies?: SearchStrategyId[]\n /** User's features for ACL filtering */\n userFeatures?: string[]\n /** Is user a super admin (bypasses ACL) */\n isSuperAdmin?: boolean\n /** Minimum score threshold (default: 0.2) */\n minScore?: number\n}\n\n/**\n * Service for searching and indexing MCP tools using hybrid search strategies.\n *\n * Uses the existing search module infrastructure to provide:\n * - Fulltext search (Meilisearch) when configured\n * - Vector/semantic search (PgVector) when configured\n * - Token-based search (PostgreSQL hash) as fallback\n *\n * Results are merged using Reciprocal Rank Fusion (RRF) with weights:\n * - fulltext: 1.2\n * - vector: 1.0\n * - tokens: 0.8\n */\nexport class ToolSearchService {\n private lastIndexChecksum: string | null = null\n\n constructor(\n private readonly searchService: SearchService,\n private readonly toolRegistry: McpToolRegistry\n ) {}\n\n /**\n * Get status of available search strategies.\n * This helps the AI understand which integrations are available.\n */\n async getStrategyStatus(): Promise<StrategyStatus> {\n const [fulltext, vector, tokens] = await Promise.all([\n this.searchService.isStrategyAvailable('fulltext'),\n this.searchService.isStrategyAvailable('vector'),\n this.searchService.isStrategyAvailable('tokens'),\n ])\n\n const available: SearchStrategyId[] = []\n if (fulltext) available.push('fulltext')\n if (vector) available.push('vector')\n if (tokens) available.push('tokens')\n\n const defaultStrategies = this.searchService.getDefaultStrategies()\n\n return {\n fulltext,\n vector,\n tokens,\n available,\n default: defaultStrategies.filter((s) => available.includes(s)),\n }\n }\n\n /**\n * Search for relevant tools using hybrid strategies.\n *\n * @param query - User's search query or message\n * @param options - Search options\n * @returns Array of matching tools sorted by relevance\n */\n async searchTools(\n query: string,\n options: ToolSearchOptions = {}\n ): Promise<ToolSearchResult[]> {\n const {\n limit = TOOL_SEARCH_CONFIG.defaultLimit,\n strategies,\n userFeatures = [],\n isSuperAdmin = false,\n minScore = TOOL_SEARCH_CONFIG.minScore,\n } = options\n\n // Use hybrid search with all available strategies\n const searchResults = await this.searchService.search(query, {\n tenantId: GLOBAL_TENANT_ID,\n organizationId: null,\n entityTypes: [TOOL_ENTITY_ID],\n strategies,\n limit: limit * 2, // Get extra results to account for ACL filtering\n })\n\n // Map to tool results and filter by ACL\n const toolResults: ToolSearchResult[] = []\n\n for (const result of searchResults) {\n // Skip results below minimum score\n if (result.score < minScore) continue\n\n const metadata = result.metadata as Record<string, unknown> | undefined\n const requiredFeatures = (metadata?.requiredFeatures as string[]) ?? []\n const moduleId = metadata?.moduleId as string | undefined\n\n // Filter by user's feature access\n if (!this.hasFeatureAccess(requiredFeatures, userFeatures, isSuperAdmin)) {\n continue\n }\n\n toolResults.push({\n toolName: result.recordId,\n score: result.score,\n moduleId,\n requiredFeatures,\n })\n\n // Stop when we have enough results\n if (toolResults.length >= limit) break\n }\n\n return toolResults\n }\n\n /**\n * Index all tools from the registry using available strategies.\n * Uses checksum-based change detection to avoid unnecessary re-indexing.\n *\n * @param force - Force re-indexing even if checksum hasn't changed\n * @returns Indexing result with statistics\n */\n async indexTools(force = false): Promise<IndexingResult> {\n const tools = Array.from(this.toolRegistry.getTools().values())\n const modules = this.getToolModules()\n\n // Compute checksum to detect changes\n const checksum = computeToolsChecksum(\n tools.map((t) => ({ name: t.name, description: t.description }))\n )\n\n // Skip if checksum matches and not forced\n if (!force && this.lastIndexChecksum === checksum) {\n const status = await this.getStrategyStatus()\n return {\n indexed: 0,\n skipped: tools.length,\n strategies: status.available,\n checksum,\n }\n }\n\n // Build indexable records for all tools\n const records: IndexableRecord[] = []\n for (const tool of tools) {\n const moduleId = modules.get(tool.name)\n records.push(toolToIndexableRecord(tool, moduleId))\n }\n\n // Bulk index using available strategies\n if (records.length > 0) {\n try {\n await this.searchService.bulkIndex(records)\n } catch (error) {\n throw new Error(\n `Failed to index ${records.length} tools: ${\n error instanceof Error ? error.message : error\n }`\n )\n }\n }\n\n // Update checksum\n this.lastIndexChecksum = checksum\n\n const status = await this.getStrategyStatus()\n return {\n indexed: records.length,\n skipped: 0,\n strategies: status.available,\n checksum,\n }\n }\n\n /**\n * Purge all tool records from the search index.\n * Useful for cleanup or complete re-indexing.\n */\n async purgeIndex(): Promise<void> {\n await this.searchService.purge(TOOL_ENTITY_ID, GLOBAL_TENANT_ID)\n this.lastIndexChecksum = null\n }\n\n /**\n * Get tools by module ID from the registry.\n */\n getToolsByModule(moduleId: string): string[] {\n return this.toolRegistry.listToolsByModule(moduleId)\n }\n\n /**\n * Build a map of tool name to module ID.\n */\n private getToolModules(): Map<string, string> {\n const modules = new Map<string, string>()\n\n // Common module prefixes that indicate tool ownership\n const modulePatterns = [\n { pattern: /^customers_/, moduleId: 'customers' },\n { pattern: /^sales_/, moduleId: 'sales' },\n { pattern: /^catalog_/, moduleId: 'catalog' },\n { pattern: /^staff_/, moduleId: 'staff' },\n { pattern: /^resources_/, moduleId: 'resources' },\n { pattern: /^planner/, moduleId: 'planner' },\n { pattern: /^search_/, moduleId: 'search' },\n { pattern: /^context_/, moduleId: 'context' },\n { pattern: /^auth_/, moduleId: 'auth' },\n { pattern: /^directory_/, moduleId: 'directory' },\n { pattern: /^dashboards_/, moduleId: 'dashboards' },\n { pattern: /^entities_/, moduleId: 'entities' },\n { pattern: /^dictionaries_/, moduleId: 'dictionaries' },\n { pattern: /^workflows_/, moduleId: 'workflows' },\n { pattern: /^business_rules_/, moduleId: 'business_rules' },\n { pattern: /^audit_logs_/, moduleId: 'audit_logs' },\n { pattern: /^attachments_/, moduleId: 'attachments' },\n { pattern: /^feature_toggles_/, moduleId: 'feature_toggles' },\n { pattern: /^currencies_/, moduleId: 'currencies' },\n { pattern: /^example_/, moduleId: 'example' },\n { pattern: /^cli_/, moduleId: 'cli' },\n ]\n\n for (const toolName of this.toolRegistry.listToolNames()) {\n for (const { pattern, moduleId } of modulePatterns) {\n if (pattern.test(toolName)) {\n modules.set(toolName, moduleId)\n break\n }\n }\n }\n\n return modules\n }\n\n /**\n * Check if user has required features to access a tool.\n */\n private hasFeatureAccess(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean\n ): boolean {\n if (isSuperAdmin) return true\n if (!requiredFeatures?.length) return true\n\n return requiredFeatures.every((required) => {\n // Direct match\n if (userFeatures.includes(required)) return true\n // Wildcard match\n if (userFeatures.includes('*')) return true\n\n // Prefix wildcard match (e.g., 'customers.*' matches 'customers.people.view')\n return userFeatures.some((feature) => {\n if (feature.endsWith('.*')) {\n const prefix = feature.slice(0, -2)\n return required.startsWith(prefix + '.')\n }\n return false\n })\n })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { SearchService } from '@open-mercato/search/service'\nimport type { SearchStrategyId, IndexableRecord } from '@open-mercato/search/types'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport type { McpToolRegistry, McpToolDefinition } from './types'\nimport {\n TOOL_ENTITY_ID,\n GLOBAL_TENANT_ID,\n TOOL_SEARCH_CONFIG,\n toolToIndexableRecord,\n computeToolsChecksum,\n} from './tool-index-config'\n\n/**\n * Result from tool search.\n */\nexport type ToolSearchResult = {\n /** Tool name (recordId) */\n toolName: string\n /** Relevance score */\n score: number\n /** Module that registered the tool */\n moduleId?: string\n /** Features required to use this tool */\n requiredFeatures?: string[]\n}\n\n/**\n * Status of available search strategies.\n */\nexport type StrategyStatus = {\n fulltext: boolean\n vector: boolean\n tokens: boolean\n /** Ordered list of available strategies */\n available: SearchStrategyId[]\n /** Strategies that will be used by default */\n default: SearchStrategyId[]\n}\n\n/**\n * Result from tool indexing operation.\n */\nexport type IndexingResult = {\n indexed: number\n skipped: number\n strategies: SearchStrategyId[]\n checksum: string\n}\n\n/**\n * Options for searching tools.\n */\nexport type ToolSearchOptions = {\n /** Maximum results to return (default: 12) */\n limit?: number\n /** Override strategies to use */\n strategies?: SearchStrategyId[]\n /** User's features for ACL filtering */\n userFeatures?: string[]\n /** Is user a super admin (bypasses ACL) */\n isSuperAdmin?: boolean\n /** Minimum score threshold (default: 0.2) */\n minScore?: number\n}\n\n/**\n * Service for searching and indexing MCP tools using hybrid search strategies.\n *\n * Uses the existing search module infrastructure to provide:\n * - Fulltext search (Meilisearch) when configured\n * - Vector/semantic search (PgVector) when configured\n * - Token-based search (PostgreSQL hash) as fallback\n *\n * Results are merged using Reciprocal Rank Fusion (RRF) with weights:\n * - fulltext: 1.2\n * - vector: 1.0\n * - tokens: 0.8\n */\nexport class ToolSearchService {\n private lastIndexChecksum: string | null = null\n\n constructor(\n private readonly searchService: SearchService,\n private readonly toolRegistry: McpToolRegistry\n ) {}\n\n /**\n * Get status of available search strategies.\n * This helps the AI understand which integrations are available.\n */\n async getStrategyStatus(): Promise<StrategyStatus> {\n const [fulltext, vector, tokens] = await Promise.all([\n this.searchService.isStrategyAvailable('fulltext'),\n this.searchService.isStrategyAvailable('vector'),\n this.searchService.isStrategyAvailable('tokens'),\n ])\n\n const available: SearchStrategyId[] = []\n if (fulltext) available.push('fulltext')\n if (vector) available.push('vector')\n if (tokens) available.push('tokens')\n\n const defaultStrategies = this.searchService.getDefaultStrategies()\n\n return {\n fulltext,\n vector,\n tokens,\n available,\n default: defaultStrategies.filter((s) => available.includes(s)),\n }\n }\n\n /**\n * Search for relevant tools using hybrid strategies.\n *\n * @param query - User's search query or message\n * @param options - Search options\n * @returns Array of matching tools sorted by relevance\n */\n async searchTools(\n query: string,\n options: ToolSearchOptions = {}\n ): Promise<ToolSearchResult[]> {\n const {\n limit = TOOL_SEARCH_CONFIG.defaultLimit,\n strategies,\n userFeatures = [],\n isSuperAdmin = false,\n minScore = TOOL_SEARCH_CONFIG.minScore,\n } = options\n\n // Use hybrid search with all available strategies\n const searchResults = await this.searchService.search(query, {\n tenantId: GLOBAL_TENANT_ID,\n organizationId: null,\n entityTypes: [TOOL_ENTITY_ID],\n strategies,\n limit: limit * 2, // Get extra results to account for ACL filtering\n })\n\n // Map to tool results and filter by ACL\n const toolResults: ToolSearchResult[] = []\n\n for (const result of searchResults) {\n // Skip results below minimum score\n if (result.score < minScore) continue\n\n const metadata = result.metadata as Record<string, unknown> | undefined\n const requiredFeatures = (metadata?.requiredFeatures as string[]) ?? []\n const moduleId = metadata?.moduleId as string | undefined\n\n // Filter by user's feature access\n if (!this.hasFeatureAccess(requiredFeatures, userFeatures, isSuperAdmin)) {\n continue\n }\n\n toolResults.push({\n toolName: result.recordId,\n score: result.score,\n moduleId,\n requiredFeatures,\n })\n\n // Stop when we have enough results\n if (toolResults.length >= limit) break\n }\n\n return toolResults\n }\n\n /**\n * Index all tools from the registry using available strategies.\n * Uses checksum-based change detection to avoid unnecessary re-indexing.\n *\n * @param force - Force re-indexing even if checksum hasn't changed\n * @returns Indexing result with statistics\n */\n async indexTools(force = false): Promise<IndexingResult> {\n const tools = Array.from(this.toolRegistry.getTools().values())\n const modules = this.getToolModules()\n\n // Compute checksum to detect changes\n const checksum = computeToolsChecksum(\n tools.map((t) => ({ name: t.name, description: t.description }))\n )\n\n // Skip if checksum matches and not forced\n if (!force && this.lastIndexChecksum === checksum) {\n const status = await this.getStrategyStatus()\n return {\n indexed: 0,\n skipped: tools.length,\n strategies: status.available,\n checksum,\n }\n }\n\n // Build indexable records for all tools\n const records: IndexableRecord[] = []\n for (const tool of tools) {\n const moduleId = modules.get(tool.name)\n records.push(toolToIndexableRecord(tool, moduleId))\n }\n\n // Bulk index using available strategies\n if (records.length > 0) {\n try {\n await this.searchService.bulkIndex(records)\n } catch (error) {\n throw new Error(\n `Failed to index ${records.length} tools: ${\n error instanceof Error ? error.message : error\n }`\n )\n }\n }\n\n // Update checksum\n this.lastIndexChecksum = checksum\n\n const status = await this.getStrategyStatus()\n return {\n indexed: records.length,\n skipped: 0,\n strategies: status.available,\n checksum,\n }\n }\n\n /**\n * Purge all tool records from the search index.\n * Useful for cleanup or complete re-indexing.\n */\n async purgeIndex(): Promise<void> {\n await this.searchService.purge(TOOL_ENTITY_ID, GLOBAL_TENANT_ID)\n this.lastIndexChecksum = null\n }\n\n /**\n * Get tools by module ID from the registry.\n */\n getToolsByModule(moduleId: string): string[] {\n return this.toolRegistry.listToolsByModule(moduleId)\n }\n\n /**\n * Build a map of tool name to module ID.\n */\n private getToolModules(): Map<string, string> {\n const modules = new Map<string, string>()\n\n // Common module prefixes that indicate tool ownership\n const modulePatterns = [\n { pattern: /^customers_/, moduleId: 'customers' },\n { pattern: /^sales_/, moduleId: 'sales' },\n { pattern: /^catalog_/, moduleId: 'catalog' },\n { pattern: /^staff_/, moduleId: 'staff' },\n { pattern: /^resources_/, moduleId: 'resources' },\n { pattern: /^planner/, moduleId: 'planner' },\n { pattern: /^search_/, moduleId: 'search' },\n { pattern: /^context_/, moduleId: 'context' },\n { pattern: /^auth_/, moduleId: 'auth' },\n { pattern: /^directory_/, moduleId: 'directory' },\n { pattern: /^dashboards_/, moduleId: 'dashboards' },\n { pattern: /^entities_/, moduleId: 'entities' },\n { pattern: /^dictionaries_/, moduleId: 'dictionaries' },\n { pattern: /^workflows_/, moduleId: 'workflows' },\n { pattern: /^business_rules_/, moduleId: 'business_rules' },\n { pattern: /^audit_logs_/, moduleId: 'audit_logs' },\n { pattern: /^attachments_/, moduleId: 'attachments' },\n { pattern: /^feature_toggles_/, moduleId: 'feature_toggles' },\n { pattern: /^currencies_/, moduleId: 'currencies' },\n { pattern: /^example_/, moduleId: 'example' },\n { pattern: /^cli_/, moduleId: 'cli' },\n ]\n\n for (const toolName of this.toolRegistry.listToolNames()) {\n for (const { pattern, moduleId } of modulePatterns) {\n if (pattern.test(toolName)) {\n modules.set(toolName, moduleId)\n break\n }\n }\n }\n\n return modules\n }\n\n /**\n * Check if user has required features to access a tool.\n */\n private hasFeatureAccess(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean\n ): boolean {\n return authorizeFeatures(requiredFeatures ?? [], {\n grantedFeatures: userFeatures,\n unrestricted: isSuperAdmin,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["status"]
7
7
  }
@@ -0,0 +1,19 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260610134045_ai_assistant extends Migration {
3
+ up() {
4
+ this.addSql(`create table "ai_moderation_flags" ("id" uuid not null default gen_random_uuid(), "tenant_id" uuid not null, "organization_id" uuid null, "agent_id" text not null, "user_id" text not null, "provider_id" text not null, "model_id" text not null, "categories" jsonb not null, "created_at" timestamptz not null, primary key ("id"));`);
5
+ this.addSql(`create index "ai_moderation_flags_tenant_user_idx" on "ai_moderation_flags" ("tenant_id", "user_id");`);
6
+ this.addSql(`create index "ai_moderation_flags_tenant_created_idx" on "ai_moderation_flags" ("tenant_id", "created_at");`);
7
+ this.addSql(`alter table "ai_agent_runtime_overrides" add "input_moderation" boolean null;`);
8
+ }
9
+ down() {
10
+ this.addSql(`alter table "ai_agent_runtime_overrides" drop column "input_moderation";`);
11
+ this.addSql(`drop index "ai_moderation_flags_tenant_created_idx";`);
12
+ this.addSql(`drop index "ai_moderation_flags_tenant_user_idx";`);
13
+ this.addSql(`drop table if exists "ai_moderation_flags" cascade;`);
14
+ }
15
+ }
16
+ export {
17
+ Migration20260610134045_ai_assistant
18
+ };
19
+ //# sourceMappingURL=Migration20260610134045_ai_assistant.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260610134045_ai_assistant extends Migration {\n\n override up(): void | Promise<void> {\n this.addSql(`create table \"ai_moderation_flags\" (\"id\" uuid not null default gen_random_uuid(), \"tenant_id\" uuid not null, \"organization_id\" uuid null, \"agent_id\" text not null, \"user_id\" text not null, \"provider_id\" text not null, \"model_id\" text not null, \"categories\" jsonb not null, \"created_at\" timestamptz not null, primary key (\"id\"));`);\n this.addSql(`create index \"ai_moderation_flags_tenant_user_idx\" on \"ai_moderation_flags\" (\"tenant_id\", \"user_id\");`);\n this.addSql(`create index \"ai_moderation_flags_tenant_created_idx\" on \"ai_moderation_flags\" (\"tenant_id\", \"created_at\");`);\n\n this.addSql(`alter table \"ai_agent_runtime_overrides\" add \"input_moderation\" boolean null;`);\n }\n\n override down(): void | Promise<void> {\n this.addSql(`alter table \"ai_agent_runtime_overrides\" drop column \"input_moderation\";`);\n\n this.addSql(`drop index \"ai_moderation_flags_tenant_created_idx\";`);\n this.addSql(`drop index \"ai_moderation_flags_tenant_user_idx\";`);\n this.addSql(`drop table if exists \"ai_moderation_flags\" cascade;`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,6CAA6C,UAAU;AAAA,EAEzD,KAA2B;AAClC,SAAK,OAAO,0UAA0U;AACtV,SAAK,OAAO,uGAAuG;AACnH,SAAK,OAAO,6GAA6G;AAEzH,SAAK,OAAO,+EAA+E;AAAA,EAC7F;AAAA,EAES,OAA6B;AACpC,SAAK,OAAO,0EAA0E;AAEtF,SAAK,OAAO,sDAAsD;AAClE,SAAK,OAAO,mDAAmD;AAC/D,SAAK,OAAO,qDAAqD;AAAA,EACnE;AAEF;",
6
+ "names": []
7
+ }
@@ -18,3 +18,4 @@ export const loop_max_wall_clock_ms = "loop_max_wall_clock_ms";
18
18
  export const loop_max_tokens = "loop_max_tokens";
19
19
  export const loop_stop_when_json = "loop_stop_when_json";
20
20
  export const loop_active_tools_json = "loop_active_tools_json";
21
+ export const input_moderation = "input_moderation";
@@ -0,0 +1,9 @@
1
+ export const id = "id";
2
+ export const tenant_id = "tenant_id";
3
+ export const organization_id = "organization_id";
4
+ export const agent_id = "agent_id";
5
+ export const user_id = "user_id";
6
+ export const provider_id = "provider_id";
7
+ export const model_id = "model_id";
8
+ export const categories = "categories";
9
+ export const created_at = "created_at";
@@ -12,7 +12,8 @@ export const M = {
12
12
  "ai_agent_mutation_policy_override": "ai_assistant:ai_agent_mutation_policy_override",
13
13
  "ai_chat_conversation": "ai_assistant:ai_chat_conversation",
14
14
  "ai_chat_conversation_participant": "ai_assistant:ai_chat_conversation_participant",
15
- "ai_chat_message": "ai_assistant:ai_chat_message"
15
+ "ai_chat_message": "ai_assistant:ai_chat_message",
16
+ "ai_moderation_flag": "ai_assistant:ai_moderation_flag"
16
17
  }
17
18
  } as const;
18
19
 
@@ -44,7 +44,8 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
44
44
  "loop_max_wall_clock_ms": "loop_max_wall_clock_ms",
45
45
  "loop_max_tokens": "loop_max_tokens",
46
46
  "loop_stop_when_json": "loop_stop_when_json",
47
- "loop_active_tools_json": "loop_active_tools_json"
47
+ "loop_active_tools_json": "loop_active_tools_json",
48
+ "input_moderation": "input_moderation"
48
49
  },
49
50
  "ai_chat_conversation": {
50
51
  "id": "id",
@@ -93,6 +94,17 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
93
94
  "updated_at": "updated_at",
94
95
  "deleted_at": "deleted_at"
95
96
  },
97
+ "ai_moderation_flag": {
98
+ "id": "id",
99
+ "tenant_id": "tenant_id",
100
+ "organization_id": "organization_id",
101
+ "agent_id": "agent_id",
102
+ "user_id": "user_id",
103
+ "provider_id": "provider_id",
104
+ "model_id": "model_id",
105
+ "categories": "categories",
106
+ "created_at": "created_at"
107
+ },
96
108
  "ai_pending_action": {
97
109
  "id": "id",
98
110
  "tenant_id": "tenant_id",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.6.7-develop.6775.1.c2313bb8a3",
3
+ "version": "0.6.7-develop.6785.1.1dd7cfac55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -99,16 +99,16 @@
99
99
  "zod-to-json-schema": "^3.25.2"
100
100
  },
101
101
  "peerDependencies": {
102
- "@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
103
- "@open-mercato/ui": "0.6.7-develop.6775.1.c2313bb8a3",
102
+ "@open-mercato/shared": "0.6.7-develop.6785.1.1dd7cfac55",
103
+ "@open-mercato/ui": "0.6.7-develop.6785.1.1dd7cfac55",
104
104
  "react": "^19.0.0",
105
105
  "react-dom": "^19.0.0",
106
106
  "zod": ">=3.23.0"
107
107
  },
108
108
  "devDependencies": {
109
- "@open-mercato/cli": "0.6.7-develop.6775.1.c2313bb8a3",
110
- "@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
111
- "@open-mercato/ui": "0.6.7-develop.6775.1.c2313bb8a3",
109
+ "@open-mercato/cli": "0.6.7-develop.6785.1.1dd7cfac55",
110
+ "@open-mercato/shared": "0.6.7-develop.6785.1.1dd7cfac55",
111
+ "@open-mercato/ui": "0.6.7-develop.6785.1.1dd7cfac55",
112
112
  "@types/react": "^19.2.17",
113
113
  "@types/react-dom": "^19.2.3",
114
114
  "react": "19.2.7",
@@ -0,0 +1,126 @@
1
+ import { test, expect, request as playwrightRequest } from '@playwright/test';
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
3
+ import { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
4
+ import {
5
+ deleteAgentOverridesInDb,
6
+ deleteModerationFlagsInDb,
7
+ seedModerationFlagInDb,
8
+ } from './helpers/aiAssistantFixtures';
9
+
10
+ /**
11
+ * TC-AI-MODERATION-008 — Input-moderation settings + audit API.
12
+ * Source: spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`.
13
+ *
14
+ * Surfaces under test:
15
+ * - PUT /api/ai_assistant/settings (persists `inputModeration` per agent)
16
+ * - GET /api/ai_assistant/settings (reflects per-agent effective moderation policy)
17
+ * - GET /api/ai_assistant/moderation-flags (tenant-scoped audit listing)
18
+ *
19
+ * Contract notes verified against the route handlers:
20
+ * - PUT `{ agentId, inputModeration }` returns 200 echoing `inputModeration`.
21
+ * - GET /settings exposes `agents[].moderation = { enforced, override, effective }`.
22
+ * - GET /moderation-flags returns `{ items, total, page, pageSize }`, tenant-scoped,
23
+ * gated by `ai_assistant.settings.manage`, `pageSize` capped at 100.
24
+ *
25
+ * Flags are seeded directly in the DB: a real flag is only ever born from the gate
26
+ * during a flagged LLM turn (needs a live moderation call), so seeding keeps the
27
+ * audit + isolation coverage deterministic and provider-free. This spec therefore
28
+ * only runs under the coherent app+DB harness (`yarn test:integration` / `:ephemeral`).
29
+ */
30
+
31
+ const SETTINGS = '/api/ai_assistant/settings';
32
+ const FLAGS = '/api/ai_assistant/moderation-flags';
33
+
34
+ interface FlagsResponse {
35
+ items: Array<{ id: string; tenantId: string; agentId: string; userId: string; categories: Record<string, unknown> }>;
36
+ total: number;
37
+ page: number;
38
+ pageSize: number;
39
+ }
40
+
41
+ test.describe('TC-AI-MODERATION-008: input moderation settings + audit', () => {
42
+ test('PUT inputModeration persists per agent and round-trips on/off/inherit', async ({ request }) => {
43
+ test.slow();
44
+ const adminToken = await getAuthToken(request, 'admin');
45
+ const { tenantId } = getTokenScope(adminToken);
46
+ // A synthetic agent id keeps the test independent of the (empty-in-CI) live
47
+ // agent registry; the PUT upserts an override row keyed by this id. The
48
+ // per-agent `moderation` reflection on GET (registered agents only) is
49
+ // covered by the route unit tests + the stubbed UI spec (TC-AI-MODERATION-009).
50
+ const agentId = 'it.moderation_settings';
51
+
52
+ try {
53
+ const on = await apiRequest(request, 'PUT', SETTINGS, {
54
+ token: adminToken,
55
+ data: { agentId, inputModeration: true },
56
+ });
57
+ expect(on.status(), 'PUT inputModeration=true returns 200').toBe(200);
58
+ expect((await readJsonSafe<{ inputModeration: boolean | null }>(on))?.inputModeration).toBe(true);
59
+
60
+ const off = await apiRequest(request, 'PUT', SETTINGS, {
61
+ token: adminToken,
62
+ data: { agentId, inputModeration: false },
63
+ });
64
+ expect(off.status()).toBe(200);
65
+ expect((await readJsonSafe<{ inputModeration: boolean | null }>(off))?.inputModeration).toBe(false);
66
+
67
+ const inherit = await apiRequest(request, 'PUT', SETTINGS, {
68
+ token: adminToken,
69
+ data: { agentId, inputModeration: null },
70
+ });
71
+ expect(inherit.status()).toBe(200);
72
+ expect((await readJsonSafe<{ inputModeration: boolean | null }>(inherit))?.inputModeration).toBeNull();
73
+ } finally {
74
+ await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => undefined);
75
+ }
76
+ });
77
+
78
+ test('GET /moderation-flags is tenant-scoped (cross-tenant rows are never returned)', async ({ request }) => {
79
+ test.slow();
80
+ const adminToken = await getAuthToken(request, 'admin');
81
+ const { tenantId } = getTokenScope(adminToken);
82
+ const otherTenantId = '00000000-0000-4000-8000-0000000face7';
83
+
84
+ let mineId = '';
85
+ try {
86
+ mineId = await seedModerationFlagInDb({ tenantId, agentId: 'it.moderation_audit', userId: 'it-user-mine' });
87
+ await seedModerationFlagInDb({ tenantId: otherTenantId, agentId: 'it.moderation_audit', userId: 'it-user-other' });
88
+
89
+ const res = await apiRequest(request, 'GET', `${FLAGS}?pageSize=100`, { token: adminToken });
90
+ expect(res.status()).toBe(200);
91
+ const body = await readJsonSafe<FlagsResponse>(res);
92
+ expect(body?.items, 'returns an items array').toBeTruthy();
93
+ const ids = (body?.items ?? []).map((row) => row.id);
94
+ expect(ids, 'my tenant flag is visible').toContain(mineId);
95
+ // Every returned row is scoped to the caller's tenant — the other-tenant row never leaks.
96
+ for (const row of body?.items ?? []) {
97
+ expect(row.tenantId).toBe(tenantId);
98
+ }
99
+ } finally {
100
+ await deleteModerationFlagsInDb(tenantId).catch(() => undefined);
101
+ await deleteModerationFlagsInDb(otherTenantId).catch(() => undefined);
102
+ }
103
+ });
104
+
105
+ test('GET /moderation-flags RBAC + validation gates', async ({ request, baseURL }) => {
106
+ const adminToken = await getAuthToken(request, 'admin');
107
+
108
+ // pageSize above the 100 cap is rejected by the zod query schema.
109
+ const tooLarge = await apiRequest(request, 'GET', `${FLAGS}?pageSize=101`, { token: adminToken });
110
+ expect(tooLarge.status()).toBe(400);
111
+ expect((await readJsonSafe<{ code?: string }>(tooLarge))?.code).toBe('validation_error');
112
+
113
+ // Employee carries ai_assistant.view but NOT ai_assistant.settings.manage.
114
+ const employeeToken = await getAuthToken(request, 'employee');
115
+ const denied = await apiRequest(request, 'GET', FLAGS, { token: employeeToken });
116
+ expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);
117
+
118
+ const anon = await playwrightRequest.newContext({ baseURL });
119
+ try {
120
+ const res = await anon.fetch(FLAGS, { method: 'GET' });
121
+ expect(res.status(), 'unauthenticated GET is 401').toBe(401);
122
+ } finally {
123
+ await anon.dispose();
124
+ }
125
+ });
126
+ });