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