@open-mercato/ai-assistant 0.6.6-develop.6286.1.d985c5c374 → 0.6.6-develop.6290.1.4bb5a8ba3f
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 +22 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js +8 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/chat/route.js +8 -0
- package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/lib/rate-limit.js +38 -0
- package/dist/modules/ai_assistant/lib/rate-limit.js.map +7 -0
- package/package.json +6 -6
- package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
- package/src/modules/ai_assistant/api/chat/route.ts +12 -0
- package/src/modules/ai_assistant/lib/__tests__/rate-limit.test.ts +177 -0
- package/src/modules/ai_assistant/lib/rate-limit.ts +84 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:ai-assistant] found
|
|
1
|
+
[build:ai-assistant] found 217 entry points
|
|
2
2
|
[build:ai-assistant] built successfully
|
package/AGENTS.md
CHANGED
|
@@ -1473,6 +1473,28 @@ Agents that need multi-step tool loops configure the `loop` block on `AiAgentDef
|
|
|
1473
1473
|
|
|
1474
1474
|
**Backward compatibility**: Strictly additive — the `rewriteGeneratedAliasImports(source, appRoot)` signature is unchanged, and `@/` aliases, bare package imports, and sibling `./` imports keep their prior behavior. Only previously-broken `../../src/...` specifiers change (from unresolved to resolved).
|
|
1475
1475
|
|
|
1476
|
+
### 2026-06-11 - Rate-limit AI chat dispatch routes (#2975)
|
|
1477
|
+
|
|
1478
|
+
**What changed**:
|
|
1479
|
+
- Both AI chat dispatch handlers — `POST /api/ai_assistant/ai/chat` (`api/ai/chat/route.ts`) and the legacy `POST /api/chat` (`api/chat/route.ts`) — now consult a per-user/per-tenant rate limit before running the LLM agent loop, per-turn DB reads/writes, and (legacy) the ephemeral `api_keys` insert.
|
|
1480
|
+
- Added `lib/rate-limit.ts` exporting `checkAiChatRateLimit({ req, container, userId, tenantId })`. It resolves the already-registered `rateLimiterService` from the request DI container (NOT a static `@open-mercato/core` import — core is not a dependency of this package), builds an additive `RATE_LIMIT_AI_CHAT_*` bucket via `readEndpointRateLimitConfig`, keys on `userId:tenantId`, and returns a typed 429 on breach.
|
|
1481
|
+
- **Fail-open**, mirroring auth's `checkAuthRateLimit`: when the limiter service is unavailable (unregistered, null, throws) the request proceeds unthrottled. No response shape, DI key, route, or event-id change.
|
|
1482
|
+
|
|
1483
|
+
**New environment variables** (all optional, additive):
|
|
1484
|
+
|
|
1485
|
+
| Variable | Default | Purpose |
|
|
1486
|
+
|----------|---------|---------|
|
|
1487
|
+
| `RATE_LIMIT_AI_CHAT_POINTS` | `30` | Max chat turns per window per user/tenant. |
|
|
1488
|
+
| `RATE_LIMIT_AI_CHAT_DURATION` | `60` | Window length (seconds). |
|
|
1489
|
+
| `RATE_LIMIT_AI_CHAT_BLOCK_DURATION` | unset | Optional block window (seconds) after breach. |
|
|
1490
|
+
| `OM_TEST_AI_CHAT_RATE_LIMIT_MODE` | unset | Set to `opt-in` (with `OM_TEST_MODE=1`) so the bucket is only exercised when a request sends `x-om-test-rate-limit: on`, mirroring auth's test opt-in. |
|
|
1491
|
+
|
|
1492
|
+
The global `RATE_LIMIT_ENABLED` kill switch (auto-off under `OM_INTEGRATION_TEST`) still disables the bucket wholesale.
|
|
1493
|
+
|
|
1494
|
+
**Files**:
|
|
1495
|
+
- Added `lib/rate-limit.ts` + `lib/__tests__/rate-limit.test.ts`.
|
|
1496
|
+
- `api/ai/chat/route.ts`, `api/chat/route.ts` — call the gate near the top of the POST handler (legacy route reuses the request container resolved for the answerQuestion ownership re-check).
|
|
1497
|
+
|
|
1476
1498
|
### 2026-06-11 - Harden latent MCP server-config module (#2672)
|
|
1477
1499
|
|
|
1478
1500
|
**What changed** (`lib/mcp-server-config.ts`, currently dead code — no callers):
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { AiTenantModelAllowlistRepository } from "../../../data/repositories/AiTenantModelAllowlistRepository.js";
|
|
25
25
|
import { AiAgentRuntimeOverrideRepository } from "../../../data/repositories/AiAgentRuntimeOverrideRepository.js";
|
|
26
26
|
import { createConversationStorage } from "../../../lib/conversation-storage.js";
|
|
27
|
+
import { checkAiChatRateLimit } from "../../../lib/rate-limit.js";
|
|
27
28
|
const MAX_MESSAGES = 100;
|
|
28
29
|
const agentIdPattern = /^[a-z0-9_]+\.[a-z0-9_]+$/;
|
|
29
30
|
const chatMessageSchema = z.object({
|
|
@@ -350,6 +351,13 @@ async function POST(req) {
|
|
|
350
351
|
try {
|
|
351
352
|
await loadAgentRegistry();
|
|
352
353
|
const container = await createRequestContainer();
|
|
354
|
+
const rateLimited = await checkAiChatRateLimit({
|
|
355
|
+
req,
|
|
356
|
+
container,
|
|
357
|
+
userId: auth.sub,
|
|
358
|
+
tenantId: auth.tenantId
|
|
359
|
+
});
|
|
360
|
+
if (rateLimited) return rateLimited;
|
|
353
361
|
const rbacService = container.resolve("rbacService");
|
|
354
362
|
const acl = await rbacService.loadAcl(auth.sub, {
|
|
355
363
|
tenantId: auth.tenantId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/ai_assistant/api/ai/chat/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\nimport type { UIMessage } from 'ai'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'\nimport { loadAgentRegistry } from '../../../lib/agent-registry'\nimport { checkAgentPolicy, type AgentPolicyDenyCode } from '../../../lib/agent-policy'\nimport {\n runAiAgentText,\n resolveLoopBudgetPreset,\n type AiAgentLoopBudgetPreset,\n} from '../../../lib/agent-runtime'\nimport { AgentPolicyError } from '../../../lib/agent-tools'\nimport { readBaseurlAllowlist, isBaseurlAllowlisted } from '../../../lib/baseurl-allowlist'\nimport {\n canonicalProviderId,\n hasAllowlistSnapshotRestrictions,\n intersectEffectiveAllowlistWithSnapshot,\n intersectAllowlists,\n isModelAllowedForProviderInEffective,\n isProviderAllowedInEffective,\n modelAllowlistEnvVarName,\n readAgentRuntimeOverrideAllowlist,\n type TenantAllowlistSnapshot,\n} from '../../../lib/model-allowlist'\nimport { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'\nimport { AiAgentRuntimeOverrideRepository } from '../../../data/repositories/AiAgentRuntimeOverrideRepository'\nimport { createConversationStorage } from '../../../lib/conversation-storage'\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nconst MAX_MESSAGES = 100\n\nconst agentIdPattern = /^[a-z0-9_]+\\.[a-z0-9_]+$/\n\nconst chatMessageSchema = z.object({\n id: z.string().min(1).max(128).optional(),\n role: z.enum(['user', 'assistant', 'system']),\n content: z.string(),\n uiParts: z.array(z.unknown()).optional(),\n files: z\n .array(\n z\n .object({\n id: z.string().optional(),\n name: z.string().optional(),\n type: z.string().optional(),\n mimeType: z.string().optional(),\n size: z.number().optional(),\n })\n .passthrough(),\n )\n .optional(),\n})\n\nconst pageContextSchema = z\n .object({\n pageId: z.string().nullable().optional(),\n entityType: z.string().nullable().optional(),\n recordId: z.string().nullable().optional(),\n })\n .passthrough()\n\nconst chatRequestSchema = z.object({\n messages: z\n .array(chatMessageSchema)\n .min(1, 'messages must contain at least one message')\n .max(MAX_MESSAGES, `messages must contain at most ${MAX_MESSAGES} entries`),\n attachmentIds: z.array(z.string()).optional(),\n debug: z.boolean().optional(),\n pageContext: pageContextSchema.optional(),\n /**\n * Stable per-conversation id (Phase 6.2). Wins over `conversationId` when\n * both are provided. The server echoes the resolved id on the SSE\n * `loop-finish` event so clients can persist it for the next turn.\n */\n sessionId: z.string().uuid().optional(),\n /**\n * @deprecated Use `sessionId` instead.\n */\n conversationId: z.string().min(1).max(128).optional(),\n})\n\nexport type AiChatRequest = z.infer<typeof chatRequestSchema>\n\nconst agentQuerySchema = z.object({\n agent: z\n .string()\n .regex(agentIdPattern, 'agent must match \"<module>.<agent>\" (lowercase, digits, underscores only)'),\n /**\n * Per-request provider override. Must match a registered + configured\n * provider id. Validated against `llmProviderRegistry` at dispatch time.\n * Rejected when the agent has `allowRuntimeOverride: false`.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n provider: z.string().optional(),\n /**\n * Per-request model id override. Free-form string. Logged (not rejected)\n * when not in the provider's curated `defaultModels` catalog.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n model: z.string().optional(),\n /**\n * Per-request base URL override. Must parse as a URL and match\n * `AI_RUNTIME_BASEURL_ALLOWLIST` (comma-separated host patterns). When the\n * env var is unset or empty, any non-empty value is rejected.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n baseUrl: z.string().optional(),\n /**\n * Named loop-budget preset. Maps to a fixed `loop.budget` triple:\n * tight \u2192 maxSteps: 3, maxWallClockMs: 10_000, maxTokens: 50_000\n * default \u2192 no override (agent default applies)\n * loose \u2192 maxSteps: 20, maxWallClockMs: 120_000, maxTokens: 500_000\n *\n * Rejected when the agent has `allowRuntimeOverride: false` or\n * `loop.allowRuntimeOverride: false`.\n *\n * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n loopBudget: z.enum(['tight', 'default', 'loose']).optional(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'AI agent dispatcher',\n methods: {\n POST: {\n operationId: 'aiAssistantChatAgent',\n summary: 'Stream a chat turn for a registered AI agent',\n description:\n 'Dispatches a chat turn to the focused AI agent identified by `?agent=<module>.<agent>`. ' +\n 'Enforces agent-level `requiredFeatures`, tool whitelisting, read-only / mutationPolicy, ' +\n 'execution-mode compatibility, and attachment media-type policy. The streaming response ' +\n 'body uses an AI SDK-compatible `text/event-stream` transport. ' +\n 'Optional `?provider=`, `?model=`, and `?baseUrl=` query params let callers ' +\n 'override the resolved provider/model/base-URL for this turn (Phase 4a). ' +\n 'Provider must be registered and configured; baseUrl must match ' +\n '`AI_RUNTIME_BASEURL_ALLOWLIST` when set. Both are suppressed when the ' +\n 'agent declares `allowRuntimeOverride: false`.',\n query: agentQuerySchema,\n requestBody: {\n contentType: 'application/json',\n description: 'Chat turn payload. `messages` is required; `attachmentIds`, `debug`, and `pageContext` are optional.',\n schema: chatRequestSchema,\n },\n responses: [\n { status: 200, description: 'Streaming text/event-stream response compatible with AI SDK chat transports.', mediaType: 'text/event-stream' },\n ],\n errors: [\n {\n status: 400,\n description:\n 'Invalid query param, malformed payload, or message count above the cap. ' +\n 'Typed codes: `runtime_override_disabled` (agent has allowRuntimeOverride:false), ' +\n '`provider_unknown` (provider id not registered), ' +\n '`provider_not_configured` (provider registered but no API key in env), ' +\n '`baseurl_not_allowlisted` (baseUrl not in AI_RUNTIME_BASEURL_ALLOWLIST).',\n },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks agent-level or tool-level required features.' },\n { status: 404, description: 'Unknown agent id.' },\n { status: 409, description: 'Agent/tool/execution-mode policy violation.' },\n { status: 500, description: 'Internal runtime failure.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nfunction statusForDenyCode(code: AgentPolicyDenyCode): number {\n switch (code) {\n case 'agent_unknown':\n return 404\n case 'agent_features_denied':\n case 'tool_features_denied':\n return 403\n case 'tool_not_whitelisted':\n case 'tool_unknown':\n case 'mutation_blocked_by_readonly':\n case 'mutation_blocked_by_policy':\n case 'execution_mode_not_supported':\n return 409\n case 'attachment_type_not_accepted':\n return 400\n default:\n return 409\n }\n}\n\nfunction extractDataPayload(eventBlock: string): string | null {\n const dataLines = eventBlock\n .split('\\n')\n .filter((line) => line.startsWith('data:'))\n .map((line) => (line.startsWith('data: ') ? line.slice(6) : line.slice(5)))\n if (dataLines.length === 0) return null\n return dataLines.join('\\n')\n}\n\nfunction extractUiPartsFromToolOutput(output: unknown): unknown[] {\n let parsed = output\n if (typeof output === 'string') {\n const trimmed = output.trim()\n if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return []\n try {\n parsed = JSON.parse(trimmed) as unknown\n } catch {\n return []\n }\n }\n if (!parsed || typeof parsed !== 'object') return []\n const value = parsed as Record<string, unknown>\n const parts: unknown[] = []\n if (value.status === 'pending-confirmation' || value.status === 'awaiting-confirmation') {\n const pendingActionId =\n typeof value.pendingActionId === 'string' && value.pendingActionId.length > 0\n ? value.pendingActionId\n : null\n if (pendingActionId) {\n parts.push({\n componentId: 'mutation-preview-card',\n pendingActionId,\n payload: {\n pendingActionId,\n expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,\n agentId:\n typeof value.agentId === 'string'\n ? value.agentId\n : typeof value.agent === 'string'\n ? value.agent\n : undefined,\n toolName: typeof value.toolName === 'string' ? value.toolName : undefined,\n },\n })\n }\n }\n if (value.uiPart && typeof value.uiPart === 'object') parts.push(value.uiPart)\n if (Array.isArray(value.uiParts)) parts.push(...value.uiParts)\n return parts\n}\n\nfunction extractAssistantSnapshot(\n raw: string,\n contentType: string | null,\n): { content: string; uiParts: unknown[] } {\n if (!contentType?.includes('event-stream')) {\n return { content: raw, uiParts: [] }\n }\n let content = ''\n const uiParts: unknown[] = []\n for (const block of raw.split('\\n\\n')) {\n const data = extractDataPayload(block)\n if (!data || data === '[DONE]') continue\n try {\n const parsed = JSON.parse(data) as Record<string, unknown>\n if (parsed.type === 'text-delta' && typeof parsed.delta === 'string') {\n content += parsed.delta\n } else if (parsed.type === 'text' && typeof parsed.content === 'string') {\n content += parsed.content\n } else if (parsed.type === 'tool-output-available') {\n uiParts.push(...extractUiPartsFromToolOutput(parsed.output))\n }\n } catch {\n // Ignore SSE comments and malformed provider chunks.\n }\n }\n return { content, uiParts }\n}\n\nasync function persistChatTurnStart(input: {\n container: Awaited<ReturnType<typeof createRequestContainer>>\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n userId: string\n agentId: string\n conversationId: string | null\n pageContext?: Record<string, unknown>\n messages: AiChatRequest['messages']\n attachmentIds?: string[]\n}): Promise<{ conversationId: string; userClientMessageId: string | null } | null> {\n if (!input.tenantId || !input.conversationId) return null\n const repo = createConversationStorage(input.container)\n const ctx = {\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n userId: input.userId,\n }\n await repo.createOrGet(\n {\n conversationId: input.conversationId,\n agentId: input.agentId,\n pageContext: input.pageContext ?? null,\n },\n ctx,\n )\n const userMessage = [...input.messages].reverse().find((message) => message.role === 'user')\n if (!userMessage) return { conversationId: input.conversationId, userClientMessageId: null }\n await repo.appendMessage(\n input.conversationId,\n {\n clientMessageId: userMessage.id,\n role: 'user',\n content: userMessage.content,\n uiParts: userMessage.uiParts,\n attachmentIds: input.attachmentIds,\n files: userMessage.files?.map((file, index) => {\n const id = file.id ?? input.attachmentIds?.[index]\n const mimeType = file.mimeType ?? file.type\n return {\n ...(id ? { id } : {}),\n ...(file.name ? { name: file.name } : {}),\n ...(mimeType ? { mimeType } : {}),\n ...(typeof file.size === 'number' ? { size: file.size } : {}),\n }\n }),\n },\n ctx,\n )\n return {\n conversationId: input.conversationId,\n userClientMessageId: userMessage.id ?? null,\n }\n}\n\nfunction persistAssistantOnStreamCompletion(input: {\n response: Response\n container: Awaited<ReturnType<typeof createRequestContainer>>\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n userId: string\n conversationId: string\n userClientMessageId: string | null\n}): Response {\n if (!input.response.body || !input.tenantId) return input.response\n const tenantId = input.tenantId\n const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()\n const writer = writable.getWriter()\n const decoder = new TextDecoder()\n const contentType = input.response.headers.get('content-type')\n\n async function pump(): Promise<void> {\n const reader = input.response.body!.getReader()\n let raw = ''\n try {\n for (;;) {\n const { value, done } = await reader.read()\n if (done) break\n if (!value) continue\n raw += decoder.decode(value, { stream: true })\n await writer.write(value)\n }\n raw += decoder.decode()\n const assistant = extractAssistantSnapshot(raw, contentType)\n if (assistant.content.trim() || assistant.uiParts.length > 0) {\n const repo = createConversationStorage(input.container)\n await repo.appendMessage(\n input.conversationId,\n {\n clientMessageId: input.userClientMessageId\n ? `${input.userClientMessageId}:assistant`\n : undefined,\n role: 'assistant',\n content: assistant.content,\n uiParts: assistant.uiParts,\n },\n {\n tenantId,\n organizationId: input.organizationId ?? null,\n userId: input.userId,\n },\n )\n }\n } catch (error) {\n console.error('[AI Chat Agent] Conversation persistence failure:', error)\n } finally {\n reader.releaseLock()\n await writer.close().catch(() => undefined)\n }\n }\n\n void pump()\n return new Response(readable, {\n status: input.response.status,\n statusText: input.response.statusText,\n headers: input.response.headers,\n })\n}\n\nexport async function POST(req: NextRequest): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const requestUrl = new URL(req.url)\n const queryResult = agentQuerySchema.safeParse({\n agent: requestUrl.searchParams.get('agent') ?? undefined,\n provider: requestUrl.searchParams.get('provider') ?? undefined,\n model: requestUrl.searchParams.get('model') ?? undefined,\n baseUrl: requestUrl.searchParams.get('baseUrl') ?? undefined,\n loopBudget: requestUrl.searchParams.get('loopBudget') ?? undefined,\n })\n if (!queryResult.success) {\n return jsonError(400, 'Invalid or missing \"agent\" query parameter.', 'validation_error', {\n issues: queryResult.error.issues,\n })\n }\n const agentId = queryResult.data.agent\n const rawProvider = queryResult.data.provider\n const rawModel = queryResult.data.model\n const rawBaseUrl = queryResult.data.baseUrl\n const rawLoopBudget = queryResult.data.loopBudget as AiAgentLoopBudgetPreset | undefined\n\n let parsedBody: unknown\n try {\n parsedBody = await req.json()\n } catch {\n return jsonError(400, 'Request body must be valid JSON.', 'validation_error')\n }\n\n const bodyResult = chatRequestSchema.safeParse(parsedBody)\n if (!bodyResult.success) {\n return jsonError(400, 'Invalid request body.', 'validation_error', {\n issues: bodyResult.error.issues,\n })\n }\n\n try {\n await loadAgentRegistry()\n\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n const decision = checkAgentPolicy({\n agentId,\n authContext: {\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n },\n requestedExecutionMode: 'chat',\n // TODO(step-3.7): resolve attachmentIds -> media types via attachment-bridge\n // once the attachment-to-model conversion bridge lands. Until then the\n // policy gate skips attachment-type validation because media types are\n // not known at dispatch time.\n attachmentMediaTypes: undefined,\n })\n\n if (!decision.ok) {\n return jsonError(statusForDenyCode(decision.code), decision.message, decision.code)\n }\n\n const agentDef = decision.agent\n\n // --- Phase 4a: validate runtime override query params ---\n const hasRuntimeOverride =\n (rawProvider && rawProvider.trim().length > 0) ||\n (rawModel && rawModel.trim().length > 0) ||\n (rawBaseUrl && rawBaseUrl.trim().length > 0) ||\n (rawLoopBudget !== undefined && rawLoopBudget !== 'default')\n\n // `allowRuntimeOverride` is the canonical flag (renamed from\n // `allowRuntimeModelOverride` in Phase 4 of this spec). Both are checked\n // here to cover agents declared before the rename lands; the deprecated\n // alias has lower priority.\n const runtimeOverrideAllowed =\n agentDef.allowRuntimeOverride !== false &&\n agentDef.allowRuntimeModelOverride !== false\n\n if (hasRuntimeOverride && !runtimeOverrideAllowed) {\n return jsonError(\n 400,\n `Agent \"${agentId}\" has runtime override disabled (allowRuntimeOverride: false).`,\n 'runtime_override_disabled',\n )\n }\n\n let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null\n let agentRuntimeOverrideAllowlist: TenantAllowlistSnapshot | null = null\n if (auth.tenantId) {\n try {\n const em = container.resolve<EntityManager>('em')\n const allowlistRepo = new AiTenantModelAllowlistRepository(em)\n tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const runtimeOverrideRepo = new AiAgentRuntimeOverrideRepository(em)\n const agentRuntimeOverrideRow = await runtimeOverrideRepo.getExact({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n agentId,\n })\n const tenantAgentAllowlist = agentRuntimeOverrideRow\n ? {\n allowedProviders: agentRuntimeOverrideRow.allowedOverrideProviders ?? null,\n allowedModelsByProvider: agentRuntimeOverrideRow.allowedOverrideModelsByProvider ?? {},\n }\n : null\n agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist)\n ? tenantAgentAllowlist\n : null\n } catch (snapshotError) {\n // Fail closed: refuse to dispatch if we cannot confirm the tenant allowlist.\n // Silently falling back to env-only would widen the effective allowlist when\n // the DB is unavailable, which is the opposite of what an admin intends.\n console.error(\n '[AI Chat Agent] Tenant allowlist lookup failed; refusing to dispatch:',\n snapshotError,\n )\n return jsonError(\n 503,\n 'Tenant allowlist is temporarily unavailable. Try again shortly.',\n 'tenant_allowlist_unavailable',\n )\n }\n }\n const knownProviderIds = llmProviderRegistry.list().map((p) => p.id)\n const baseEffectiveAllowlist = intersectAllowlists(\n process.env as Record<string, string | undefined>,\n knownProviderIds,\n tenantAllowlistSnapshot,\n )\n const envAgentAllowlist = readAgentRuntimeOverrideAllowlist(\n process.env as Record<string, string | undefined>,\n agentId,\n knownProviderIds,\n )\n const effectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(\n intersectEffectiveAllowlistWithSnapshot(\n baseEffectiveAllowlist,\n knownProviderIds,\n envAgentAllowlist,\n ),\n knownProviderIds,\n agentRuntimeOverrideAllowlist,\n )\n\n const normalizedProvider = rawProvider && rawProvider.trim().length > 0\n ? canonicalProviderId(rawProvider.trim(), llmProviderRegistry.list().map((p) => p.id))\n : null\n\n if (rawProvider && rawProvider.trim().length > 0) {\n const providerEntry = normalizedProvider ? llmProviderRegistry.get(normalizedProvider) : null\n if (!providerEntry) {\n return jsonError(\n 400,\n `Provider \"${rawProvider}\" is not registered. Registered provider ids: ${llmProviderRegistry.list().map((p) => p.id).join(', ')}.`,\n 'provider_unknown',\n )\n }\n if (!providerEntry.isConfigured()) {\n return jsonError(\n 400,\n `Provider \"${rawProvider}\" is registered but not configured in this environment (missing API key).`,\n 'provider_not_configured',\n )\n }\n if (!isProviderAllowedInEffective(effectiveAllowlist, normalizedProvider!)) {\n const source = effectiveAllowlist.tenantOverridesActive\n ? 'the effective allowlist (env \u2229 tenant)'\n : 'OM_AI_AVAILABLE_PROVIDERS'\n return jsonError(\n 400,\n `Provider \"${rawProvider}\" is not in ${source}.`,\n 'provider_not_allowlisted',\n )\n }\n if (\n rawModel\n && rawModel.trim().length > 0\n && !isModelAllowedForProviderInEffective(\n effectiveAllowlist,\n normalizedProvider!,\n rawModel.trim(),\n )\n ) {\n const source = effectiveAllowlist.tenantOverridesActive\n ? `the effective allowlist (env \u2229 tenant) for \"${normalizedProvider}\"`\n : modelAllowlistEnvVarName(normalizedProvider!)\n return jsonError(\n 400,\n `Model \"${rawModel}\" is not in ${source}.`,\n 'model_not_allowlisted',\n )\n }\n }\n\n if (rawBaseUrl && rawBaseUrl.trim().length > 0) {\n const allowlist = readBaseurlAllowlist()\n if (!isBaseurlAllowlisted(rawBaseUrl.trim(), allowlist)) {\n return jsonError(\n 400,\n `baseUrl \"${rawBaseUrl}\" is not in the AI_RUNTIME_BASEURL_ALLOWLIST. Set that env var to a comma-separated list of allowed host patterns to enable per-request baseUrl overrides.`,\n 'baseurl_not_allowlisted',\n )\n }\n }\n // --- end Phase 4a + Phase 4 validation ---\n\n const requestOverride =\n hasRuntimeOverride\n ? {\n providerId: normalizedProvider,\n modelId: rawModel && rawModel.trim().length > 0 ? rawModel.trim() : null,\n baseURL: rawBaseUrl && rawBaseUrl.trim().length > 0 ? rawBaseUrl.trim() : null,\n }\n : undefined\n\n // Resolve the loopBudget preset to a loop config override (Phase 4).\n const loopFromPreset =\n rawLoopBudget !== undefined && rawLoopBudget !== 'default'\n ? resolveLoopBudgetPreset(rawLoopBudget)\n : undefined\n\n const effectiveConversationId = bodyResult.data.sessionId ?? bodyResult.data.conversationId ?? null\n let persistedTurn:\n | { conversationId: string; userClientMessageId: string | null }\n | null = null\n try {\n persistedTurn = await persistChatTurnStart({\n container,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n agentId,\n conversationId: effectiveConversationId,\n pageContext: bodyResult.data.pageContext,\n messages: bodyResult.data.messages,\n attachmentIds: bodyResult.data.attachmentIds,\n })\n } catch (error) {\n if (error instanceof Error && error.name === 'AiChatConversationOrgNotFoundError') {\n return jsonError(400, error.message, 'organization_not_found')\n }\n console.error('[AI Chat Agent] Failed to persist user message:', error)\n }\n\n const response = await runAiAgentText({\n agentId,\n messages: bodyResult.data.messages as unknown as UIMessage[],\n attachmentIds: bodyResult.data.attachmentIds,\n pageContext: bodyResult.data.pageContext,\n debug: bodyResult.data.debug,\n sessionId: bodyResult.data.sessionId ?? null,\n conversationId: bodyResult.data.conversationId ?? null,\n authContext: {\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n },\n container,\n requestOverride,\n loop: loopFromPreset,\n emitLoopTrace: true,\n })\n if (!persistedTurn) return response\n return persistAssistantOnStreamCompletion({\n response,\n container,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n conversationId: persistedTurn.conversationId,\n userClientMessageId: persistedTurn.userClientMessageId,\n })\n } catch (error) {\n if (error instanceof AgentPolicyError) {\n return jsonError(statusForDenyCode(error.code), error.message, error.code)\n }\n console.error('[AI Chat Agent] Dispatch failure:', error)\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Agent dispatch failed.',\n 'internal_error',\n )\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAsC;AAE/C,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,SAAS,wBAAkD;AAC3D;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,wBAAwB;AACjC,SAAS,sBAAsB,4BAA4B;AAC3D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,wCAAwC;AACjD,SAAS,wCAAwC;AACjD,SAAS,iCAAiC;AAG1C,MAAM,eAAe;AAErB,MAAM,iBAAiB;AAEvB,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EACxC,MAAM,EAAE,KAAK,CAAC,QAAQ,aAAa,QAAQ,CAAC;AAAA,EAC5C,SAAS,EAAE,OAAO;AAAA,EAClB,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS;AAAA,EACvC,OAAO,EACJ;AAAA,IACC,EACG,OAAO;AAAA,MACN,IAAI,EAAE,OAAO,EAAE,SAAS;AAAA,MACxB,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,MAC1B,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,MAC1B,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA,MAC9B,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,IAC5B,CAAC,EACA,YAAY;AAAA,EACjB,EACC,SAAS;AACd,CAAC;AAED,MAAM,oBAAoB,EACvB,OAAO;AAAA,EACN,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACvC,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAC3C,CAAC,EACA,YAAY;AAEf,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,UAAU,EACP,MAAM,iBAAiB,EACvB,IAAI,GAAG,4CAA4C,EACnD,IAAI,cAAc,iCAAiC,YAAY,UAAU;AAAA,EAC5E,eAAe,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS;AAAA,EAC5C,OAAO,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC5B,aAAa,kBAAkB,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMxC,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA,EAItC,gBAAgB,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AACtD,CAAC;AAID,MAAM,mBAAmB,EAAE,OAAO;AAAA,EAChC,OAAO,EACJ,OAAO,EACP,MAAM,gBAAgB,2EAA2E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQpG,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO9B,OAAO,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQ3B,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAY7B,YAAY,EAAE,KAAK,CAAC,SAAS,WAAW,OAAO,CAAC,EAAE,SAAS;AAC7D,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,aAAa;AAAA,MACb,SAAS;AAAA,MACT,aACE;AAAA,MASF,OAAO;AAAA,MACP,aAAa;AAAA,QACX,aAAa;AAAA,QACb,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,gFAAgF,WAAW,oBAAoB;AAAA,MAC7I;AAAA,MACA,QAAQ;AAAA,QACN;AAAA,UACE,QAAQ;AAAA,UACR,aACE;AAAA,QAKJ;AAAA,QACA,EAAE,QAAQ,KAAK,aAAa,0BAA0B;AAAA,QACtD,EAAE,QAAQ,KAAK,aAAa,4DAA4D;AAAA,QACxF,EAAE,QAAQ,KAAK,aAAa,oBAAoB;AAAA,QAChD,EAAE,QAAQ,KAAK,aAAa,8CAA8C;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,4BAA4B;AAAA,MAC1D;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,SAAS,UACP,QACA,SACA,MACA,OACc;AACd,SAAO,aAAa,KAAK,EAAE,OAAO,SAAS,MAAM,GAAI,SAAS,CAAC,EAAG,GAAG,EAAE,OAAO,CAAC;AACjF;AAEA,SAAS,kBAAkB,MAAmC;AAC5D,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAEA,SAAS,mBAAmB,YAAmC;AAC7D,QAAM,YAAY,WACf,MAAM,IAAI,EACV,OAAO,CAAC,SAAS,KAAK,WAAW,OAAO,CAAC,EACzC,IAAI,CAAC,SAAU,KAAK,WAAW,QAAQ,IAAI,KAAK,MAAM,CAAC,IAAI,KAAK,MAAM,CAAC,CAAE;AAC5E,MAAI,UAAU,WAAW,EAAG,QAAO;AACnC,SAAO,UAAU,KAAK,IAAI;AAC5B;AAEA,SAAS,6BAA6B,QAA4B;AAChE,MAAI,SAAS;AACb,MAAI,OAAO,WAAW,UAAU;AAC9B,UAAM,UAAU,OAAO,KAAK;AAC5B,QAAI,CAAC,QAAQ,WAAW,GAAG,KAAK,CAAC,QAAQ,WAAW,GAAG,EAAG,QAAO,CAAC;AAClE,QAAI;AACF,eAAS,KAAK,MAAM,OAAO;AAAA,IAC7B,QAAQ;AACN,aAAO,CAAC;AAAA,IACV;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO,CAAC;AACnD,QAAM,QAAQ;AACd,QAAM,QAAmB,CAAC;AAC1B,MAAI,MAAM,WAAW,0BAA0B,MAAM,WAAW,yBAAyB;AACvF,UAAM,kBACJ,OAAO,MAAM,oBAAoB,YAAY,MAAM,gBAAgB,SAAS,IACxE,MAAM,kBACN;AACN,QAAI,iBAAiB;AACnB,YAAM,KAAK;AAAA,QACT,aAAa;AAAA,QACb;AAAA,QACA,SAAS;AAAA,UACP;AAAA,UACA,WAAW,OAAO,MAAM,cAAc,WAAW,MAAM,YAAY;AAAA,UACnE,SACE,OAAO,MAAM,YAAY,WACrB,MAAM,UACN,OAAO,MAAM,UAAU,WACrB,MAAM,QACN;AAAA,UACR,UAAU,OAAO,MAAM,aAAa,WAAW,MAAM,WAAW;AAAA,QAClE;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACA,MAAI,MAAM,UAAU,OAAO,MAAM,WAAW,SAAU,OAAM,KAAK,MAAM,MAAM;AAC7E,MAAI,MAAM,QAAQ,MAAM,OAAO,EAAG,OAAM,KAAK,GAAG,MAAM,OAAO;AAC7D,SAAO;AACT;AAEA,SAAS,yBACP,KACA,aACyC;AACzC,MAAI,CAAC,aAAa,SAAS,cAAc,GAAG;AAC1C,WAAO,EAAE,SAAS,KAAK,SAAS,CAAC,EAAE;AAAA,EACrC;AACA,MAAI,UAAU;AACd,QAAM,UAAqB,CAAC;AAC5B,aAAW,SAAS,IAAI,MAAM,MAAM,GAAG;AACrC,UAAM,OAAO,mBAAmB,KAAK;AACrC,QAAI,CAAC,QAAQ,SAAS,SAAU;AAChC,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,UAAI,OAAO,SAAS,gBAAgB,OAAO,OAAO,UAAU,UAAU;AACpE,mBAAW,OAAO;AAAA,MACpB,WAAW,OAAO,SAAS,UAAU,OAAO,OAAO,YAAY,UAAU;AACvE,mBAAW,OAAO;AAAA,MACpB,WAAW,OAAO,SAAS,yBAAyB;AAClD,gBAAQ,KAAK,GAAG,6BAA6B,OAAO,MAAM,CAAC;AAAA,MAC7D;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO,EAAE,SAAS,QAAQ;AAC5B;AAEA,eAAe,qBAAqB,OAU+C;AACjF,MAAI,CAAC,MAAM,YAAY,CAAC,MAAM,eAAgB,QAAO;AACrD,QAAM,OAAO,0BAA0B,MAAM,SAAS;AACtD,QAAM,MAAM;AAAA,IACV,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM,kBAAkB;AAAA,IACxC,QAAQ,MAAM;AAAA,EAChB;AACA,QAAM,KAAK;AAAA,IACT;AAAA,MACE,gBAAgB,MAAM;AAAA,MACtB,SAAS,MAAM;AAAA,MACf,aAAa,MAAM,eAAe;AAAA,IACpC;AAAA,IACA;AAAA,EACF;AACA,QAAM,cAAc,CAAC,GAAG,MAAM,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,YAAY,QAAQ,SAAS,MAAM;AAC3F,MAAI,CAAC,YAAa,QAAO,EAAE,gBAAgB,MAAM,gBAAgB,qBAAqB,KAAK;AAC3F,QAAM,KAAK;AAAA,IACT,MAAM;AAAA,IACN;AAAA,MACE,iBAAiB,YAAY;AAAA,MAC7B,MAAM;AAAA,MACN,SAAS,YAAY;AAAA,MACrB,SAAS,YAAY;AAAA,MACrB,eAAe,MAAM;AAAA,MACrB,OAAO,YAAY,OAAO,IAAI,CAAC,MAAM,UAAU;AAC7C,cAAM,KAAK,KAAK,MAAM,MAAM,gBAAgB,KAAK;AACjD,cAAM,WAAW,KAAK,YAAY,KAAK;AACvC,eAAO;AAAA,UACL,GAAI,KAAK,EAAE,GAAG,IAAI,CAAC;AAAA,UACnB,GAAI,KAAK,OAAO,EAAE,MAAM,KAAK,KAAK,IAAI,CAAC;AAAA,UACvC,GAAI,WAAW,EAAE,SAAS,IAAI,CAAC;AAAA,UAC/B,GAAI,OAAO,KAAK,SAAS,WAAW,EAAE,MAAM,KAAK,KAAK,IAAI,CAAC;AAAA,QAC7D;AAAA,MACF,CAAC;AAAA,IACH;AAAA,IACA;AAAA,EACF;AACA,SAAO;AAAA,IACL,gBAAgB,MAAM;AAAA,IACtB,qBAAqB,YAAY,MAAM;AAAA,EACzC;AACF;AAEA,SAAS,mCAAmC,OAQ/B;AACX,MAAI,CAAC,MAAM,SAAS,QAAQ,CAAC,MAAM,SAAU,QAAO,MAAM;AAC1D,QAAM,WAAW,MAAM;AACvB,QAAM,EAAE,UAAU,SAAS,IAAI,IAAI,gBAAwC;AAC3E,QAAM,SAAS,SAAS,UAAU;AAClC,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,cAAc,MAAM,SAAS,QAAQ,IAAI,cAAc;AAE7D,iBAAe,OAAsB;AACnC,UAAM,SAAS,MAAM,SAAS,KAAM,UAAU;AAC9C,QAAI,MAAM;AACV,QAAI;AACF,iBAAS;AACP,cAAM,EAAE,OAAO,KAAK,IAAI,MAAM,OAAO,KAAK;AAC1C,YAAI,KAAM;AACV,YAAI,CAAC,MAAO;AACZ,eAAO,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAC7C,cAAM,OAAO,MAAM,KAAK;AAAA,MAC1B;AACA,aAAO,QAAQ,OAAO;AACtB,YAAM,YAAY,yBAAyB,KAAK,WAAW;AAC3D,UAAI,UAAU,QAAQ,KAAK,KAAK,UAAU,QAAQ,SAAS,GAAG;AAC5D,cAAM,OAAO,0BAA0B,MAAM,SAAS;AACtD,cAAM,KAAK;AAAA,UACT,MAAM;AAAA,UACN;AAAA,YACE,iBAAiB,MAAM,sBACnB,GAAG,MAAM,mBAAmB,eAC5B;AAAA,YACJ,MAAM;AAAA,YACN,SAAS,UAAU;AAAA,YACnB,SAAS,UAAU;AAAA,UACrB;AAAA,UACA;AAAA,YACE;AAAA,YACA,gBAAgB,MAAM,kBAAkB;AAAA,YACxC,QAAQ,MAAM;AAAA,UAChB;AAAA,QACF;AAAA,MACF;AAAA,IACF,SAAS,OAAO;AACd,cAAQ,MAAM,qDAAqD,KAAK;AAAA,IAC1E,UAAE;AACA,aAAO,YAAY;AACnB,YAAM,OAAO,MAAM,EAAE,MAAM,MAAM,MAAS;AAAA,IAC5C;AAAA,EACF;AAEA,OAAK,KAAK;AACV,SAAO,IAAI,SAAS,UAAU;AAAA,IAC5B,QAAQ,MAAM,SAAS;AAAA,IACvB,YAAY,MAAM,SAAS;AAAA,IAC3B,SAAS,MAAM,SAAS;AAAA,EAC1B,CAAC;AACH;AAEA,eAAsB,KAAK,KAAqC;AAC9D,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM;AACT,WAAO,UAAU,KAAK,gBAAgB,iBAAiB;AAAA,EACzD;AAEA,QAAM,aAAa,IAAI,IAAI,IAAI,GAAG;AAClC,QAAM,cAAc,iBAAiB,UAAU;AAAA,IAC7C,OAAO,WAAW,aAAa,IAAI,OAAO,KAAK;AAAA,IAC/C,UAAU,WAAW,aAAa,IAAI,UAAU,KAAK;AAAA,IACrD,OAAO,WAAW,aAAa,IAAI,OAAO,KAAK;AAAA,IAC/C,SAAS,WAAW,aAAa,IAAI,SAAS,KAAK;AAAA,IACnD,YAAY,WAAW,aAAa,IAAI,YAAY,KAAK;AAAA,EAC3D,CAAC;AACD,MAAI,CAAC,YAAY,SAAS;AACxB,WAAO,UAAU,KAAK,+CAA+C,oBAAoB;AAAA,MACvF,QAAQ,YAAY,MAAM;AAAA,IAC5B,CAAC;AAAA,EACH;AACA,QAAM,UAAU,YAAY,KAAK;AACjC,QAAM,cAAc,YAAY,KAAK;AACrC,QAAM,WAAW,YAAY,KAAK;AAClC,QAAM,aAAa,YAAY,KAAK;AACpC,QAAM,gBAAgB,YAAY,KAAK;AAEvC,MAAI;AACJ,MAAI;AACF,iBAAa,MAAM,IAAI,KAAK;AAAA,EAC9B,QAAQ;AACN,WAAO,UAAU,KAAK,oCAAoC,kBAAkB;AAAA,EAC9E;AAEA,QAAM,aAAa,kBAAkB,UAAU,UAAU;AACzD,MAAI,CAAC,WAAW,SAAS;AACvB,WAAO,UAAU,KAAK,yBAAyB,oBAAoB;AAAA,MACjE,QAAQ,WAAW,MAAM;AAAA,IAC3B,CAAC;AAAA,EACH;AAEA,MAAI;AACF,UAAM,kBAAkB;AAExB,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,cAAc,UAAU,QAAqB,aAAa;AAChE,UAAM,MAAM,MAAM,YAAY,QAAQ,KAAK,KAAK;AAAA,MAC9C,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,IACvB,CAAC;AAED,UAAM,WAAW,iBAAiB;AAAA,MAChC;AAAA,MACA,aAAa;AAAA,QACX,cAAc,IAAI;AAAA,QAClB,cAAc,IAAI;AAAA,MACpB;AAAA,MACA,wBAAwB;AAAA;AAAA;AAAA;AAAA;AAAA,MAKxB,sBAAsB;AAAA,IACxB,CAAC;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,aAAO,UAAU,kBAAkB,SAAS,IAAI,GAAG,SAAS,SAAS,SAAS,IAAI;AAAA,IACpF;AAEA,UAAM,WAAW,SAAS;AAG1B,UAAM,qBACH,eAAe,YAAY,KAAK,EAAE,SAAS,KAC3C,YAAY,SAAS,KAAK,EAAE,SAAS,KACrC,cAAc,WAAW,KAAK,EAAE,SAAS,KACzC,kBAAkB,UAAa,kBAAkB;AAMpD,UAAM,yBACJ,SAAS,yBAAyB,SAClC,SAAS,8BAA8B;AAEzC,QAAI,sBAAsB,CAAC,wBAAwB;AACjD,aAAO;AAAA,QACL;AAAA,QACA,UAAU,OAAO;AAAA,QACjB;AAAA,MACF;AAAA,IACF;AAEA,QAAI,0BAA0D;AAC9D,QAAI,gCAAgE;AACpE,QAAI,KAAK,UAAU;AACjB,UAAI;AACF,cAAM,KAAK,UAAU,QAAuB,IAAI;AAChD,cAAM,gBAAgB,IAAI,iCAAiC,EAAE;AAC7D,kCAA0B,MAAM,cAAc,YAAY;AAAA,UACxD,UAAU,KAAK;AAAA,UACf,gBAAgB,KAAK,SAAS;AAAA,QAChC,CAAC;AACD,cAAM,sBAAsB,IAAI,iCAAiC,EAAE;AACnE,cAAM,0BAA0B,MAAM,oBAAoB,SAAS;AAAA,UACjE,UAAU,KAAK;AAAA,UACf,gBAAgB,KAAK,SAAS;AAAA,UAC9B;AAAA,QACF,CAAC;AACD,cAAM,uBAAuB,0BACzB;AAAA,UACE,kBAAkB,wBAAwB,4BAA4B;AAAA,UACtE,yBAAyB,wBAAwB,mCAAmC,CAAC;AAAA,QACvF,IACA;AACJ,wCAAgC,iCAAiC,oBAAoB,IACjF,uBACA;AAAA,MACN,SAAS,eAAe;AAItB,gBAAQ;AAAA,UACN;AAAA,UACA;AAAA,QACF;AACA,eAAO;AAAA,UACL;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,UAAM,mBAAmB,oBAAoB,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AACnE,UAAM,yBAAyB;AAAA,MAC7B,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF;AACA,UAAM,oBAAoB;AAAA,MACxB,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF;AACA,UAAM,qBAAqB;AAAA,MACzB;AAAA,QACE;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAEA,UAAM,qBAAqB,eAAe,YAAY,KAAK,EAAE,SAAS,IAClE,oBAAoB,YAAY,KAAK,GAAG,oBAAoB,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC,IACnF;AAEJ,QAAI,eAAe,YAAY,KAAK,EAAE,SAAS,GAAG;AAChD,YAAM,gBAAgB,qBAAqB,oBAAoB,IAAI,kBAAkB,IAAI;AACzF,UAAI,CAAC,eAAe;AAClB,eAAO;AAAA,UACL;AAAA,UACA,aAAa,WAAW,iDAAiD,oBAAoB,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,KAAK,IAAI,CAAC;AAAA,UAC/H;AAAA,QACF;AAAA,MACF;AACA,UAAI,CAAC,cAAc,aAAa,GAAG;AACjC,eAAO;AAAA,UACL;AAAA,UACA,aAAa,WAAW;AAAA,UACxB;AAAA,QACF;AAAA,MACF;AACA,UAAI,CAAC,6BAA6B,oBAAoB,kBAAmB,GAAG;AAC1E,cAAM,SAAS,mBAAmB,wBAC9B,gDACA;AACJ,eAAO;AAAA,UACL;AAAA,UACA,aAAa,WAAW,eAAe,MAAM;AAAA,UAC7C;AAAA,QACF;AAAA,MACF;AACA,UACE,YACG,SAAS,KAAK,EAAE,SAAS,KACzB,CAAC;AAAA,QACF;AAAA,QACA;AAAA,QACA,SAAS,KAAK;AAAA,MAChB,GACA;AACA,cAAM,SAAS,mBAAmB,wBAC9B,oDAA+C,kBAAkB,MACjE,yBAAyB,kBAAmB;AAChD,eAAO;AAAA,UACL;AAAA,UACA,UAAU,QAAQ,eAAe,MAAM;AAAA,UACvC;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,QAAI,cAAc,WAAW,KAAK,EAAE,SAAS,GAAG;AAC9C,YAAM,YAAY,qBAAqB;AACvC,UAAI,CAAC,qBAAqB,WAAW,KAAK,GAAG,SAAS,GAAG;AACvD,eAAO;AAAA,UACL;AAAA,UACA,YAAY,UAAU;AAAA,UACtB;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAGA,UAAM,kBACJ,qBACI;AAAA,MACE,YAAY;AAAA,MACZ,SAAS,YAAY,SAAS,KAAK,EAAE,SAAS,IAAI,SAAS,KAAK,IAAI;AAAA,MACpE,SAAS,cAAc,WAAW,KAAK,EAAE,SAAS,IAAI,WAAW,KAAK,IAAI;AAAA,IAC5E,IACA;AAGN,UAAM,iBACJ,kBAAkB,UAAa,kBAAkB,YAC7C,wBAAwB,aAAa,IACrC;AAEN,UAAM,0BAA0B,WAAW,KAAK,aAAa,WAAW,KAAK,kBAAkB;AAC/F,QAAI,gBAEO;AACX,QAAI;AACF,sBAAgB,MAAM,qBAAqB;AAAA,QACzC;AAAA,QACA,UAAU,KAAK,YAAY;AAAA,QAC3B,gBAAgB,KAAK,SAAS;AAAA,QAC9B,QAAQ,KAAK;AAAA,QACb;AAAA,QACA,gBAAgB;AAAA,QAChB,aAAa,WAAW,KAAK;AAAA,QAC7B,UAAU,WAAW,KAAK;AAAA,QAC1B,eAAe,WAAW,KAAK;AAAA,MACjC,CAAC;AAAA,IACH,SAAS,OAAO;AACd,UAAI,iBAAiB,SAAS,MAAM,SAAS,sCAAsC;AACjF,eAAO,UAAU,KAAK,MAAM,SAAS,wBAAwB;AAAA,MAC/D;AACA,cAAQ,MAAM,mDAAmD,KAAK;AAAA,IACxE;AAEA,UAAM,WAAW,MAAM,eAAe;AAAA,MACpC;AAAA,MACA,UAAU,WAAW,KAAK;AAAA,MAC1B,eAAe,WAAW,KAAK;AAAA,MAC/B,aAAa,WAAW,KAAK;AAAA,MAC7B,OAAO,WAAW,KAAK;AAAA,MACvB,WAAW,WAAW,KAAK,aAAa;AAAA,MACxC,gBAAgB,WAAW,KAAK,kBAAkB;AAAA,MAClD,aAAa;AAAA,QACX,UAAU,KAAK,YAAY;AAAA,QAC3B,gBAAgB,KAAK,SAAS;AAAA,QAC9B,QAAQ,KAAK;AAAA,QACb,UAAU,IAAI;AAAA,QACd,cAAc,IAAI;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM;AAAA,MACN,eAAe;AAAA,IACjB,CAAC;AACD,QAAI,CAAC,cAAe,QAAO;AAC3B,WAAO,mCAAmC;AAAA,MACxC;AAAA,MACA;AAAA,MACA,UAAU,KAAK,YAAY;AAAA,MAC3B,gBAAgB,KAAK,SAAS;AAAA,MAC9B,QAAQ,KAAK;AAAA,MACb,gBAAgB,cAAc;AAAA,MAC9B,qBAAqB,cAAc;AAAA,IACrC,CAAC;AAAA,EACH,SAAS,OAAO;AACd,QAAI,iBAAiB,kBAAkB;AACrC,aAAO,UAAU,kBAAkB,MAAM,IAAI,GAAG,MAAM,SAAS,MAAM,IAAI;AAAA,IAC3E;AACA,YAAQ,MAAM,qCAAqC,KAAK;AACxD,WAAO;AAAA,MACL;AAAA,MACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MACzC;AAAA,IACF;AAAA,EACF;AACF;",
|
|
4
|
+
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\nimport type { UIMessage } from 'ai'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'\nimport { loadAgentRegistry } from '../../../lib/agent-registry'\nimport { checkAgentPolicy, type AgentPolicyDenyCode } from '../../../lib/agent-policy'\nimport {\n runAiAgentText,\n resolveLoopBudgetPreset,\n type AiAgentLoopBudgetPreset,\n} from '../../../lib/agent-runtime'\nimport { AgentPolicyError } from '../../../lib/agent-tools'\nimport { readBaseurlAllowlist, isBaseurlAllowlisted } from '../../../lib/baseurl-allowlist'\nimport {\n canonicalProviderId,\n hasAllowlistSnapshotRestrictions,\n intersectEffectiveAllowlistWithSnapshot,\n intersectAllowlists,\n isModelAllowedForProviderInEffective,\n isProviderAllowedInEffective,\n modelAllowlistEnvVarName,\n readAgentRuntimeOverrideAllowlist,\n type TenantAllowlistSnapshot,\n} from '../../../lib/model-allowlist'\nimport { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'\nimport { AiAgentRuntimeOverrideRepository } from '../../../data/repositories/AiAgentRuntimeOverrideRepository'\nimport { createConversationStorage } from '../../../lib/conversation-storage'\nimport { checkAiChatRateLimit } from '../../../lib/rate-limit'\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nconst MAX_MESSAGES = 100\n\nconst agentIdPattern = /^[a-z0-9_]+\\.[a-z0-9_]+$/\n\nconst chatMessageSchema = z.object({\n id: z.string().min(1).max(128).optional(),\n role: z.enum(['user', 'assistant', 'system']),\n content: z.string(),\n uiParts: z.array(z.unknown()).optional(),\n files: z\n .array(\n z\n .object({\n id: z.string().optional(),\n name: z.string().optional(),\n type: z.string().optional(),\n mimeType: z.string().optional(),\n size: z.number().optional(),\n })\n .passthrough(),\n )\n .optional(),\n})\n\nconst pageContextSchema = z\n .object({\n pageId: z.string().nullable().optional(),\n entityType: z.string().nullable().optional(),\n recordId: z.string().nullable().optional(),\n })\n .passthrough()\n\nconst chatRequestSchema = z.object({\n messages: z\n .array(chatMessageSchema)\n .min(1, 'messages must contain at least one message')\n .max(MAX_MESSAGES, `messages must contain at most ${MAX_MESSAGES} entries`),\n attachmentIds: z.array(z.string()).optional(),\n debug: z.boolean().optional(),\n pageContext: pageContextSchema.optional(),\n /**\n * Stable per-conversation id (Phase 6.2). Wins over `conversationId` when\n * both are provided. The server echoes the resolved id on the SSE\n * `loop-finish` event so clients can persist it for the next turn.\n */\n sessionId: z.string().uuid().optional(),\n /**\n * @deprecated Use `sessionId` instead.\n */\n conversationId: z.string().min(1).max(128).optional(),\n})\n\nexport type AiChatRequest = z.infer<typeof chatRequestSchema>\n\nconst agentQuerySchema = z.object({\n agent: z\n .string()\n .regex(agentIdPattern, 'agent must match \"<module>.<agent>\" (lowercase, digits, underscores only)'),\n /**\n * Per-request provider override. Must match a registered + configured\n * provider id. Validated against `llmProviderRegistry` at dispatch time.\n * Rejected when the agent has `allowRuntimeOverride: false`.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n provider: z.string().optional(),\n /**\n * Per-request model id override. Free-form string. Logged (not rejected)\n * when not in the provider's curated `defaultModels` catalog.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n model: z.string().optional(),\n /**\n * Per-request base URL override. Must parse as a URL and match\n * `AI_RUNTIME_BASEURL_ALLOWLIST` (comma-separated host patterns). When the\n * env var is unset or empty, any non-empty value is rejected.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n baseUrl: z.string().optional(),\n /**\n * Named loop-budget preset. Maps to a fixed `loop.budget` triple:\n * tight \u2192 maxSteps: 3, maxWallClockMs: 10_000, maxTokens: 50_000\n * default \u2192 no override (agent default applies)\n * loose \u2192 maxSteps: 20, maxWallClockMs: 120_000, maxTokens: 500_000\n *\n * Rejected when the agent has `allowRuntimeOverride: false` or\n * `loop.allowRuntimeOverride: false`.\n *\n * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n loopBudget: z.enum(['tight', 'default', 'loose']).optional(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'AI agent dispatcher',\n methods: {\n POST: {\n operationId: 'aiAssistantChatAgent',\n summary: 'Stream a chat turn for a registered AI agent',\n description:\n 'Dispatches a chat turn to the focused AI agent identified by `?agent=<module>.<agent>`. ' +\n 'Enforces agent-level `requiredFeatures`, tool whitelisting, read-only / mutationPolicy, ' +\n 'execution-mode compatibility, and attachment media-type policy. The streaming response ' +\n 'body uses an AI SDK-compatible `text/event-stream` transport. ' +\n 'Optional `?provider=`, `?model=`, and `?baseUrl=` query params let callers ' +\n 'override the resolved provider/model/base-URL for this turn (Phase 4a). ' +\n 'Provider must be registered and configured; baseUrl must match ' +\n '`AI_RUNTIME_BASEURL_ALLOWLIST` when set. Both are suppressed when the ' +\n 'agent declares `allowRuntimeOverride: false`.',\n query: agentQuerySchema,\n requestBody: {\n contentType: 'application/json',\n description: 'Chat turn payload. `messages` is required; `attachmentIds`, `debug`, and `pageContext` are optional.',\n schema: chatRequestSchema,\n },\n responses: [\n { status: 200, description: 'Streaming text/event-stream response compatible with AI SDK chat transports.', mediaType: 'text/event-stream' },\n ],\n errors: [\n {\n status: 400,\n description:\n 'Invalid query param, malformed payload, or message count above the cap. ' +\n 'Typed codes: `runtime_override_disabled` (agent has allowRuntimeOverride:false), ' +\n '`provider_unknown` (provider id not registered), ' +\n '`provider_not_configured` (provider registered but no API key in env), ' +\n '`baseurl_not_allowlisted` (baseUrl not in AI_RUNTIME_BASEURL_ALLOWLIST).',\n },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks agent-level or tool-level required features.' },\n { status: 404, description: 'Unknown agent id.' },\n { status: 409, description: 'Agent/tool/execution-mode policy violation.' },\n { status: 500, description: 'Internal runtime failure.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nfunction statusForDenyCode(code: AgentPolicyDenyCode): number {\n switch (code) {\n case 'agent_unknown':\n return 404\n case 'agent_features_denied':\n case 'tool_features_denied':\n return 403\n case 'tool_not_whitelisted':\n case 'tool_unknown':\n case 'mutation_blocked_by_readonly':\n case 'mutation_blocked_by_policy':\n case 'execution_mode_not_supported':\n return 409\n case 'attachment_type_not_accepted':\n return 400\n default:\n return 409\n }\n}\n\nfunction extractDataPayload(eventBlock: string): string | null {\n const dataLines = eventBlock\n .split('\\n')\n .filter((line) => line.startsWith('data:'))\n .map((line) => (line.startsWith('data: ') ? line.slice(6) : line.slice(5)))\n if (dataLines.length === 0) return null\n return dataLines.join('\\n')\n}\n\nfunction extractUiPartsFromToolOutput(output: unknown): unknown[] {\n let parsed = output\n if (typeof output === 'string') {\n const trimmed = output.trim()\n if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return []\n try {\n parsed = JSON.parse(trimmed) as unknown\n } catch {\n return []\n }\n }\n if (!parsed || typeof parsed !== 'object') return []\n const value = parsed as Record<string, unknown>\n const parts: unknown[] = []\n if (value.status === 'pending-confirmation' || value.status === 'awaiting-confirmation') {\n const pendingActionId =\n typeof value.pendingActionId === 'string' && value.pendingActionId.length > 0\n ? value.pendingActionId\n : null\n if (pendingActionId) {\n parts.push({\n componentId: 'mutation-preview-card',\n pendingActionId,\n payload: {\n pendingActionId,\n expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined,\n agentId:\n typeof value.agentId === 'string'\n ? value.agentId\n : typeof value.agent === 'string'\n ? value.agent\n : undefined,\n toolName: typeof value.toolName === 'string' ? value.toolName : undefined,\n },\n })\n }\n }\n if (value.uiPart && typeof value.uiPart === 'object') parts.push(value.uiPart)\n if (Array.isArray(value.uiParts)) parts.push(...value.uiParts)\n return parts\n}\n\nfunction extractAssistantSnapshot(\n raw: string,\n contentType: string | null,\n): { content: string; uiParts: unknown[] } {\n if (!contentType?.includes('event-stream')) {\n return { content: raw, uiParts: [] }\n }\n let content = ''\n const uiParts: unknown[] = []\n for (const block of raw.split('\\n\\n')) {\n const data = extractDataPayload(block)\n if (!data || data === '[DONE]') continue\n try {\n const parsed = JSON.parse(data) as Record<string, unknown>\n if (parsed.type === 'text-delta' && typeof parsed.delta === 'string') {\n content += parsed.delta\n } else if (parsed.type === 'text' && typeof parsed.content === 'string') {\n content += parsed.content\n } else if (parsed.type === 'tool-output-available') {\n uiParts.push(...extractUiPartsFromToolOutput(parsed.output))\n }\n } catch {\n // Ignore SSE comments and malformed provider chunks.\n }\n }\n return { content, uiParts }\n}\n\nasync function persistChatTurnStart(input: {\n container: Awaited<ReturnType<typeof createRequestContainer>>\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n userId: string\n agentId: string\n conversationId: string | null\n pageContext?: Record<string, unknown>\n messages: AiChatRequest['messages']\n attachmentIds?: string[]\n}): Promise<{ conversationId: string; userClientMessageId: string | null } | null> {\n if (!input.tenantId || !input.conversationId) return null\n const repo = createConversationStorage(input.container)\n const ctx = {\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n userId: input.userId,\n }\n await repo.createOrGet(\n {\n conversationId: input.conversationId,\n agentId: input.agentId,\n pageContext: input.pageContext ?? null,\n },\n ctx,\n )\n const userMessage = [...input.messages].reverse().find((message) => message.role === 'user')\n if (!userMessage) return { conversationId: input.conversationId, userClientMessageId: null }\n await repo.appendMessage(\n input.conversationId,\n {\n clientMessageId: userMessage.id,\n role: 'user',\n content: userMessage.content,\n uiParts: userMessage.uiParts,\n attachmentIds: input.attachmentIds,\n files: userMessage.files?.map((file, index) => {\n const id = file.id ?? input.attachmentIds?.[index]\n const mimeType = file.mimeType ?? file.type\n return {\n ...(id ? { id } : {}),\n ...(file.name ? { name: file.name } : {}),\n ...(mimeType ? { mimeType } : {}),\n ...(typeof file.size === 'number' ? { size: file.size } : {}),\n }\n }),\n },\n ctx,\n )\n return {\n conversationId: input.conversationId,\n userClientMessageId: userMessage.id ?? null,\n }\n}\n\nfunction persistAssistantOnStreamCompletion(input: {\n response: Response\n container: Awaited<ReturnType<typeof createRequestContainer>>\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n userId: string\n conversationId: string\n userClientMessageId: string | null\n}): Response {\n if (!input.response.body || !input.tenantId) return input.response\n const tenantId = input.tenantId\n const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()\n const writer = writable.getWriter()\n const decoder = new TextDecoder()\n const contentType = input.response.headers.get('content-type')\n\n async function pump(): Promise<void> {\n const reader = input.response.body!.getReader()\n let raw = ''\n try {\n for (;;) {\n const { value, done } = await reader.read()\n if (done) break\n if (!value) continue\n raw += decoder.decode(value, { stream: true })\n await writer.write(value)\n }\n raw += decoder.decode()\n const assistant = extractAssistantSnapshot(raw, contentType)\n if (assistant.content.trim() || assistant.uiParts.length > 0) {\n const repo = createConversationStorage(input.container)\n await repo.appendMessage(\n input.conversationId,\n {\n clientMessageId: input.userClientMessageId\n ? `${input.userClientMessageId}:assistant`\n : undefined,\n role: 'assistant',\n content: assistant.content,\n uiParts: assistant.uiParts,\n },\n {\n tenantId,\n organizationId: input.organizationId ?? null,\n userId: input.userId,\n },\n )\n }\n } catch (error) {\n console.error('[AI Chat Agent] Conversation persistence failure:', error)\n } finally {\n reader.releaseLock()\n await writer.close().catch(() => undefined)\n }\n }\n\n void pump()\n return new Response(readable, {\n status: input.response.status,\n statusText: input.response.statusText,\n headers: input.response.headers,\n })\n}\n\nexport async function POST(req: NextRequest): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const requestUrl = new URL(req.url)\n const queryResult = agentQuerySchema.safeParse({\n agent: requestUrl.searchParams.get('agent') ?? undefined,\n provider: requestUrl.searchParams.get('provider') ?? undefined,\n model: requestUrl.searchParams.get('model') ?? undefined,\n baseUrl: requestUrl.searchParams.get('baseUrl') ?? undefined,\n loopBudget: requestUrl.searchParams.get('loopBudget') ?? undefined,\n })\n if (!queryResult.success) {\n return jsonError(400, 'Invalid or missing \"agent\" query parameter.', 'validation_error', {\n issues: queryResult.error.issues,\n })\n }\n const agentId = queryResult.data.agent\n const rawProvider = queryResult.data.provider\n const rawModel = queryResult.data.model\n const rawBaseUrl = queryResult.data.baseUrl\n const rawLoopBudget = queryResult.data.loopBudget as AiAgentLoopBudgetPreset | undefined\n\n let parsedBody: unknown\n try {\n parsedBody = await req.json()\n } catch {\n return jsonError(400, 'Request body must be valid JSON.', 'validation_error')\n }\n\n const bodyResult = chatRequestSchema.safeParse(parsedBody)\n if (!bodyResult.success) {\n return jsonError(400, 'Invalid request body.', 'validation_error', {\n issues: bodyResult.error.issues,\n })\n }\n\n try {\n await loadAgentRegistry()\n\n const container = await createRequestContainer()\n\n const rateLimited = await checkAiChatRateLimit({\n req,\n container,\n userId: auth.sub,\n tenantId: auth.tenantId,\n })\n if (rateLimited) return rateLimited\n\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n const decision = checkAgentPolicy({\n agentId,\n authContext: {\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n },\n requestedExecutionMode: 'chat',\n // TODO(step-3.7): resolve attachmentIds -> media types via attachment-bridge\n // once the attachment-to-model conversion bridge lands. Until then the\n // policy gate skips attachment-type validation because media types are\n // not known at dispatch time.\n attachmentMediaTypes: undefined,\n })\n\n if (!decision.ok) {\n return jsonError(statusForDenyCode(decision.code), decision.message, decision.code)\n }\n\n const agentDef = decision.agent\n\n // --- Phase 4a: validate runtime override query params ---\n const hasRuntimeOverride =\n (rawProvider && rawProvider.trim().length > 0) ||\n (rawModel && rawModel.trim().length > 0) ||\n (rawBaseUrl && rawBaseUrl.trim().length > 0) ||\n (rawLoopBudget !== undefined && rawLoopBudget !== 'default')\n\n // `allowRuntimeOverride` is the canonical flag (renamed from\n // `allowRuntimeModelOverride` in Phase 4 of this spec). Both are checked\n // here to cover agents declared before the rename lands; the deprecated\n // alias has lower priority.\n const runtimeOverrideAllowed =\n agentDef.allowRuntimeOverride !== false &&\n agentDef.allowRuntimeModelOverride !== false\n\n if (hasRuntimeOverride && !runtimeOverrideAllowed) {\n return jsonError(\n 400,\n `Agent \"${agentId}\" has runtime override disabled (allowRuntimeOverride: false).`,\n 'runtime_override_disabled',\n )\n }\n\n let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null\n let agentRuntimeOverrideAllowlist: TenantAllowlistSnapshot | null = null\n if (auth.tenantId) {\n try {\n const em = container.resolve<EntityManager>('em')\n const allowlistRepo = new AiTenantModelAllowlistRepository(em)\n tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const runtimeOverrideRepo = new AiAgentRuntimeOverrideRepository(em)\n const agentRuntimeOverrideRow = await runtimeOverrideRepo.getExact({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n agentId,\n })\n const tenantAgentAllowlist = agentRuntimeOverrideRow\n ? {\n allowedProviders: agentRuntimeOverrideRow.allowedOverrideProviders ?? null,\n allowedModelsByProvider: agentRuntimeOverrideRow.allowedOverrideModelsByProvider ?? {},\n }\n : null\n agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist)\n ? tenantAgentAllowlist\n : null\n } catch (snapshotError) {\n // Fail closed: refuse to dispatch if we cannot confirm the tenant allowlist.\n // Silently falling back to env-only would widen the effective allowlist when\n // the DB is unavailable, which is the opposite of what an admin intends.\n console.error(\n '[AI Chat Agent] Tenant allowlist lookup failed; refusing to dispatch:',\n snapshotError,\n )\n return jsonError(\n 503,\n 'Tenant allowlist is temporarily unavailable. Try again shortly.',\n 'tenant_allowlist_unavailable',\n )\n }\n }\n const knownProviderIds = llmProviderRegistry.list().map((p) => p.id)\n const baseEffectiveAllowlist = intersectAllowlists(\n process.env as Record<string, string | undefined>,\n knownProviderIds,\n tenantAllowlistSnapshot,\n )\n const envAgentAllowlist = readAgentRuntimeOverrideAllowlist(\n process.env as Record<string, string | undefined>,\n agentId,\n knownProviderIds,\n )\n const effectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(\n intersectEffectiveAllowlistWithSnapshot(\n baseEffectiveAllowlist,\n knownProviderIds,\n envAgentAllowlist,\n ),\n knownProviderIds,\n agentRuntimeOverrideAllowlist,\n )\n\n const normalizedProvider = rawProvider && rawProvider.trim().length > 0\n ? canonicalProviderId(rawProvider.trim(), llmProviderRegistry.list().map((p) => p.id))\n : null\n\n if (rawProvider && rawProvider.trim().length > 0) {\n const providerEntry = normalizedProvider ? llmProviderRegistry.get(normalizedProvider) : null\n if (!providerEntry) {\n return jsonError(\n 400,\n `Provider \"${rawProvider}\" is not registered. Registered provider ids: ${llmProviderRegistry.list().map((p) => p.id).join(', ')}.`,\n 'provider_unknown',\n )\n }\n if (!providerEntry.isConfigured()) {\n return jsonError(\n 400,\n `Provider \"${rawProvider}\" is registered but not configured in this environment (missing API key).`,\n 'provider_not_configured',\n )\n }\n if (!isProviderAllowedInEffective(effectiveAllowlist, normalizedProvider!)) {\n const source = effectiveAllowlist.tenantOverridesActive\n ? 'the effective allowlist (env \u2229 tenant)'\n : 'OM_AI_AVAILABLE_PROVIDERS'\n return jsonError(\n 400,\n `Provider \"${rawProvider}\" is not in ${source}.`,\n 'provider_not_allowlisted',\n )\n }\n if (\n rawModel\n && rawModel.trim().length > 0\n && !isModelAllowedForProviderInEffective(\n effectiveAllowlist,\n normalizedProvider!,\n rawModel.trim(),\n )\n ) {\n const source = effectiveAllowlist.tenantOverridesActive\n ? `the effective allowlist (env \u2229 tenant) for \"${normalizedProvider}\"`\n : modelAllowlistEnvVarName(normalizedProvider!)\n return jsonError(\n 400,\n `Model \"${rawModel}\" is not in ${source}.`,\n 'model_not_allowlisted',\n )\n }\n }\n\n if (rawBaseUrl && rawBaseUrl.trim().length > 0) {\n const allowlist = readBaseurlAllowlist()\n if (!isBaseurlAllowlisted(rawBaseUrl.trim(), allowlist)) {\n return jsonError(\n 400,\n `baseUrl \"${rawBaseUrl}\" is not in the AI_RUNTIME_BASEURL_ALLOWLIST. Set that env var to a comma-separated list of allowed host patterns to enable per-request baseUrl overrides.`,\n 'baseurl_not_allowlisted',\n )\n }\n }\n // --- end Phase 4a + Phase 4 validation ---\n\n const requestOverride =\n hasRuntimeOverride\n ? {\n providerId: normalizedProvider,\n modelId: rawModel && rawModel.trim().length > 0 ? rawModel.trim() : null,\n baseURL: rawBaseUrl && rawBaseUrl.trim().length > 0 ? rawBaseUrl.trim() : null,\n }\n : undefined\n\n // Resolve the loopBudget preset to a loop config override (Phase 4).\n const loopFromPreset =\n rawLoopBudget !== undefined && rawLoopBudget !== 'default'\n ? resolveLoopBudgetPreset(rawLoopBudget)\n : undefined\n\n const effectiveConversationId = bodyResult.data.sessionId ?? bodyResult.data.conversationId ?? null\n let persistedTurn:\n | { conversationId: string; userClientMessageId: string | null }\n | null = null\n try {\n persistedTurn = await persistChatTurnStart({\n container,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n agentId,\n conversationId: effectiveConversationId,\n pageContext: bodyResult.data.pageContext,\n messages: bodyResult.data.messages,\n attachmentIds: bodyResult.data.attachmentIds,\n })\n } catch (error) {\n if (error instanceof Error && error.name === 'AiChatConversationOrgNotFoundError') {\n return jsonError(400, error.message, 'organization_not_found')\n }\n console.error('[AI Chat Agent] Failed to persist user message:', error)\n }\n\n const response = await runAiAgentText({\n agentId,\n messages: bodyResult.data.messages as unknown as UIMessage[],\n attachmentIds: bodyResult.data.attachmentIds,\n pageContext: bodyResult.data.pageContext,\n debug: bodyResult.data.debug,\n sessionId: bodyResult.data.sessionId ?? null,\n conversationId: bodyResult.data.conversationId ?? null,\n authContext: {\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n },\n container,\n requestOverride,\n loop: loopFromPreset,\n emitLoopTrace: true,\n })\n if (!persistedTurn) return response\n return persistAssistantOnStreamCompletion({\n response,\n container,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n conversationId: persistedTurn.conversationId,\n userClientMessageId: persistedTurn.userClientMessageId,\n })\n } catch (error) {\n if (error instanceof AgentPolicyError) {\n return jsonError(statusForDenyCode(error.code), error.message, error.code)\n }\n console.error('[AI Chat Agent] Dispatch failure:', error)\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Agent dispatch failed.',\n 'internal_error',\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@open-mercato/core/modules/api_keys/services/apiKeyService";
|
|
14
14
|
import { UserRole } from "@open-mercato/core/modules/auth/data/entities";
|
|
15
15
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
16
|
+
import { checkAiChatRateLimit } from "../../lib/rate-limit.js";
|
|
16
17
|
const CHAT_SYSTEM_INSTRUCTIONS = `
|
|
17
18
|
You are a helpful business assistant for Open Mercato.
|
|
18
19
|
|
|
@@ -202,6 +203,13 @@ async function POST(req) {
|
|
|
202
203
|
if (!lastUserMessage) {
|
|
203
204
|
return NextResponse.json({ error: "No user message found" }, { status: 400 });
|
|
204
205
|
}
|
|
206
|
+
const rateLimited = await checkAiChatRateLimit({
|
|
207
|
+
req,
|
|
208
|
+
container,
|
|
209
|
+
userId: auth.sub,
|
|
210
|
+
tenantId: auth.tenantId
|
|
211
|
+
});
|
|
212
|
+
if (rateLimited) return rateLimited;
|
|
205
213
|
const chatStartTime = Date.now();
|
|
206
214
|
const messagePreview = lastUserMessage.slice(0, 80).replace(/\n/g, " ");
|
|
207
215
|
console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + "..." : "new"} message="${messagePreview}${lastUserMessage.length > 80 ? "..." : ""}"`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/ai_assistant/api/chat/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport {\n handleOpenCodeMessageStreaming,\n type OpenCodeAuthContext,\n type OpenCodeStreamEvent,\n} from '../../lib/opencode-handlers'\nimport { createOpenCodeClient } from '../../lib/opencode-client'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n generateSessionToken,\n createSessionApiKey,\n bindOpencodeSessionToApiKey,\n findApiKeyByOpencodeSessionId,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\n/**\n * System instructions injected at the start of new chat sessions.\n * These ensure the AI follows the correct workflow for data operations.\n */\nconst CHAT_SYSTEM_INSTRUCTIONS = `\nYou are a helpful business assistant for Open Mercato.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nABSOLUTE RULES \u2014 FOLLOW THESE OR BE CUT OFF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n1. READ = GET only. If the user says find/list/show/search/get \u2192 use only GET. NEVER call PUT/POST/DELETE for a read query.\n2. PUT path = collection path. id goes in the BODY, not the URL. Example: PUT /api/customers/companies with { id: '...', name: 'New' }. There are NO /{id} path segments.\n3. Confirm before ANY write. Before POST/PUT/DELETE: present your plan in business language, then STOP and wait for user to say \"yes\". Do NOT execute the write in the same turn.\n4. Maximum 4 tool calls per message. Hard limit is 10.\n\nYou have 2 tools \u2014 both accept a \"code\" parameter with an async JavaScript arrow function.\n\nTOOL: search \u2014 discover endpoints and schemas (READ-ONLY, fast)\n - spec.findEndpoints(keyword) \u2192 [{ path, methods }]\n - spec.describeEndpoint(path, method) \u2192 COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }\n - spec.describeEntity(keyword) \u2192 { className, fields, relationships }\n\nTOOL: execute \u2014 make API calls (reads and writes)\n - api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }\n\nCOMMON API PATHS (use directly \u2014 do NOT call findEndpoints for these):\n /api/customers/companies \u2014 companies (GET list, POST create, PUT update)\n /api/customers/people \u2014 contacts/people\n /api/customers/deals \u2014 deals/opportunities\n /api/customers/activities \u2014 activities/tasks\n /api/sales/orders \u2014 sales orders\n /api/sales/quotes \u2014 quotes\n /api/sales/invoices \u2014 invoices\n /api/catalog/products \u2014 products\n /api/catalog/categories \u2014 categories\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nRECIPES \u2014 follow EXACTLY for each task type\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nFIND/LIST records (1 call):\n For COMMON PATHS: skip describeEndpoint, go straight to execute.\n 1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })\n The \"search\" query param only matches indexed text fields \u2014 it will NOT match concepts like \"Polish\" or \"large\".\n For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.\n For unknown paths: 1 search + 1 execute.\n\nUPDATE a record (3-4 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') \u2192 learn requestBody fields AND relatedEntity\n 2. execute: GET the record \u2192 find it, get its ID\n 3. execute: PUT to the COLLECTION path with id IN THE BODY:\n api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })\n NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.\n\nCREATE a record (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 gives requiredFields, optionalFields, nestedCollections, and a working example\n 2. Ask user for confirmation with the field values\n 3. execute: api.request({ method: 'POST', ...body })\n If the endpoint has nestedCollections (like lines), include them INLINE in the body \u2014 do NOT create them separately.\n Use the \"example\" from describeEndpoint as your template \u2014 fill in real values.\n Example \u2014 create a quote with line items:\n api.request({ method: 'POST', path: '/api/sales/quotes', body: {\n currencyCode: 'EUR', customerEntityId: '<company-uuid>',\n lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]\n }})\n Do NOT create lines separately. Do NOT include id, quoteId, or total fields \u2014 the server generates them.\n\nCREATE MULTIPLE records (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 learn fields + example\n 2. execute: loop in one call:\n async () => {\n const results = [];\n for (const item of items) {\n results.push(await api.request({ method: 'POST', path: '...', body: item }));\n }\n return results;\n }\n\nDISCOVER (what endpoints/entities exist) (1 call):\n 1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nHARD RULES\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n- MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.\n- NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above \u2014 use them directly with execute.\n- NEVER call describeEntity if describeEndpoint already returned relatedEntity.\n- NEVER repeat a search from earlier in the conversation \u2014 reuse previous results.\n- NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.\n- When you already have the data you need from a previous call, use it \u2014 do NOT fetch more data to \"enrich\" it.\n- Do NOT write JavaScript filters/regex to match records. Fetch data with a simple api.request() call and use YOUR knowledge to interpret the results.\n- The \"search\" query param is fulltext only \u2014 it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.\n- describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template \u2014 fill in real values and send it.\n- For fields you don't know, OMIT them \u2014 the API uses defaults for optional fields.\n- NEVER try to set computed/total fields (amounts, totals, counts) \u2014 the server calculates them.\n- For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.\n- For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.\n\nRESPONSE RULES:\n- Be proactive \u2014 fetch data and present results, don't ask what the user wants to see.\n- Never show technical terms, IDs, JSON, or internal reasoning.\n- Present results in clean business language with **bold names** and bullet points.\n- Only ask for confirmation before create/update/delete operations.\n`.trim()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'AI chat',\n methods: {\n POST: { summary: 'Send message to AI agent via SSE stream' },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\n/**\n * Get user's role IDs from the database.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null\n): Promise<string[]> {\n if (!tenantId) return []\n\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((l) => (l.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\n/**\n * Chat endpoint that routes messages to OpenCode agent.\n * OpenCode connects to MCP server for tool access (search, execute, context_whoami).\n *\n * Emits verbose SSE events for debugging:\n * - thinking: Agent started processing\n * - metadata: Model, tokens, timing info\n * - tool-call: Tool invocation with args\n * - tool-result: Tool response\n * - text: Response text\n * - question: Confirmation question from agent\n * - done: Complete with session ID\n * - error: Error occurred\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { messages, sessionId, answerQuestion } = body as {\n messages?: Array<{ role: string; content: string }>\n sessionId?: string\n // For answering a question\n answerQuestion?: {\n questionId: string\n answer: number\n sessionId: string\n }\n }\n\n // Create SSE stream for frontend compatibility\n const encoder = new TextEncoder()\n const stream = new TransformStream()\n const writer = stream.writable.getWriter()\n let writerClosed = false\n\n const writeSSE = async (event: OpenCodeStreamEvent | { type: string; [key: string]: unknown }) => {\n if (writerClosed) return // Guard against writes after close\n try {\n const jsonStr = JSON.stringify(event)\n await writer.write(encoder.encode(`data: ${jsonStr}\\n\\n`))\n } catch (err) {\n // Writer may have been closed by client disconnect\n console.warn('[AI Chat] Failed to write SSE event:', event.type)\n }\n }\n\n const closeWriter = async () => {\n if (writerClosed) return\n writerClosed = true\n try {\n await writer.close()\n } catch {\n // Already closed\n }\n }\n\n // Resolve a request-scoped EntityManager once \u2014 needed both by the\n // answerQuestion short-circuit (for ownership re-check) and by the\n // streaming branch (for session-token mint + post-`done` binding).\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const opencodeAuth: OpenCodeAuthContext = {\n userId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n }\n\n // Handle question answer - simple JSON response, not SSE\n // The original SSE stream continues and will receive the follow-up response\n if (answerQuestion) {\n try {\n const client = createOpenCodeClient()\n // Resolve the question's actual sessionID from OpenCode \u2014 never\n // trust the caller-supplied `answerQuestion.sessionId` blindly.\n const pending = await client.getPendingQuestions()\n const matching = pending.find((q) => q.id === answerQuestion.questionId)\n if (!matching) {\n // Unknown / stale question id \u2014 same opaque response as a\n // foreign-owner mismatch to avoid leaking which case occurred.\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n if (matching.sessionID !== answerQuestion.sessionId) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n // Look up the api_key row bound to this OpenCode session and\n // assert ownership matches the authenticated caller.\n const ownerRow = await findApiKeyByOpencodeSessionId(em, matching.sessionID)\n if (\n !ownerRow ||\n ownerRow.sessionUserId !== opencodeAuth.userId ||\n (ownerRow.tenantId ?? null) !== opencodeAuth.tenantId ||\n (ownerRow.organizationId ?? null) !== opencodeAuth.organizationId\n ) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n\n await client.answerQuestion(answerQuestion.questionId, answerQuestion.answer)\n return NextResponse.json({ success: true })\n } catch (error) {\n // Fail-closed: any failure during the answerQuestion path \u2014 ownership\n // mismatch, OpenCode unreachable, getPendingQuestions throwing,\n // findApiKeyByOpencodeSessionId throwing \u2014 returns the same opaque\n // 403 envelope used for ownership rejection. This matches finding #1's\n // security intent that the answerQuestion short-circuit MUST NOT leak\n // whether the failure was authorization vs backend connectivity vs\n // implementation error. The real reason is captured in server logs.\n console.error('[AI Chat] Answer error:', error)\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n }\n\n // Handle regular message\n if (!messages || !Array.isArray(messages)) {\n return NextResponse.json({ error: 'messages array is required' }, { status: 400 })\n }\n\n // Get the latest user message\n const lastUserMessage = messages.filter((m) => m.role === 'user').pop()?.content\n if (!lastUserMessage) {\n return NextResponse.json({ error: 'No user message found' }, { status: 400 })\n }\n\n const chatStartTime = Date.now()\n const messagePreview = lastUserMessage.slice(0, 80).replace(/\\n/g, ' ')\n console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + '...' : 'new'} message=\"${messagePreview}${lastUserMessage.length > 80 ? '...' : ''}\"`)\n\n\n // For new sessions, create an ephemeral API key that inherits user permissions\n // The API key secret is encrypted and stored; MCP server recovers it via session token\n let sessionToken: string | null = null\n if (!sessionId) {\n try {\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n sessionToken = generateSessionToken()\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: 120,\n })\n console.log('[AI Chat] Created session token:', sessionToken.slice(0, 12) + '...')\n } catch (error) {\n console.error('[AI Chat] Failed to create session key:', error)\n // Continue without session key - tools will use static API key auth\n }\n }\n\n // Build the message to send to OpenCode\n // For NEW sessions: inject system instructions + session token\n // For existing sessions: only inject session token if available\n let messageToSend = ''\n\n // For NEW sessions only, prepend system instructions\n if (!sessionId) {\n messageToSend = `${CHAT_SYSTEM_INSTRUCTIONS}\\n\\n`\n }\n\n // If we have a session token, prepend explicit instructions for the AI to include it in tool calls\n if (sessionToken) {\n messageToSend += `[Session Authorization: ${sessionToken}. Include \"_sessionToken\": \"${sessionToken}\" in EVERY tool call.]\\n\\n`\n }\n\n messageToSend += lastUserMessage\n\n // Process in background - starts AFTER Response is returned so there's a reader for the stream\n ;(async () => {\n let toolCallCount = 0\n let lastTokens: { input?: number; output?: number } | undefined\n let resultSessionId: string | undefined\n\n try {\n // Emit session-authorized event first (if we have a token)\n if (sessionToken) {\n console.log('[AI Chat] Emitting session-authorized event')\n await writeSSE({\n type: 'session-authorized',\n sessionToken: sessionToken.slice(0, 12) + '...',\n })\n }\n\n // Emit thinking event for UX feedback\n await writeSSE({ type: 'thinking' })\n\n // Use streaming handler that supports questions\n await handleOpenCodeMessageStreaming(\n {\n message: messageToSend,\n sessionId,\n auth: opencodeAuth,\n em,\n },\n async (event) => {\n // Track usage from stream events\n if (event.type === 'tool-call') toolCallCount++\n if (event.type === 'metadata' && 'tokens' in event) lastTokens = event.tokens\n if (event.type === 'done' && 'sessionId' in event) {\n resultSessionId = event.sessionId\n // First-time binding: when the caller did NOT supply a\n // sessionId (i.e., this is a freshly minted chat) and we\n // successfully created a session token earlier, bind the\n // newly-resolved OpenCode session id to that api_key row so\n // subsequent resumes can be authorized.\n if (!sessionId && sessionToken && event.sessionId) {\n try {\n await bindOpencodeSessionToApiKey(em, sessionToken, event.sessionId)\n } catch (bindErr) {\n // The response stream is already in-flight \u2014 surface the\n // failure to logs without disturbing the SSE pipeline.\n console.error(\n '[AI Chat] Failed to bind OpenCode session to api_key:',\n bindErr\n )\n }\n }\n }\n\n await writeSSE(event)\n }\n )\n } catch (error) {\n console.error('[AI Chat] OpenCode error:', error)\n await writeSSE({\n type: 'error',\n error: error instanceof Error ? error.message : 'OpenCode request failed',\n })\n } finally {\n const durationMs = Date.now() - chatStartTime\n console.error(`[AI Usage] Chat complete: user=${auth.sub} session=${(resultSessionId || sessionId || 'unknown').slice(0, 16)}... duration=${durationMs}ms toolCalls=${toolCallCount}${lastTokens ? ` tokens={in:${lastTokens.input || 0},out:${lastTokens.output || 0}}` : ''}`)\n await closeWriter()\n }\n })()\n\n return new Response(stream.readable, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n })\n } catch (error) {\n console.error('[AI Chat] Error:', error)\n return NextResponse.json({ error: 'Chat request failed' }, { status: 500 })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAsC;AAE/C,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,OAGK;AACP,SAAS,4BAA4B;AACrC,SAAS,8BAA8B;AAEvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;
|
|
4
|
+
"sourcesContent": ["import { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport {\n handleOpenCodeMessageStreaming,\n type OpenCodeAuthContext,\n type OpenCodeStreamEvent,\n} from '../../lib/opencode-handlers'\nimport { createOpenCodeClient } from '../../lib/opencode-client'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n generateSessionToken,\n createSessionApiKey,\n bindOpencodeSessionToApiKey,\n findApiKeyByOpencodeSessionId,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { checkAiChatRateLimit } from '../../lib/rate-limit'\n\n/**\n * System instructions injected at the start of new chat sessions.\n * These ensure the AI follows the correct workflow for data operations.\n */\nconst CHAT_SYSTEM_INSTRUCTIONS = `\nYou are a helpful business assistant for Open Mercato.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nABSOLUTE RULES \u2014 FOLLOW THESE OR BE CUT OFF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n1. READ = GET only. If the user says find/list/show/search/get \u2192 use only GET. NEVER call PUT/POST/DELETE for a read query.\n2. PUT path = collection path. id goes in the BODY, not the URL. Example: PUT /api/customers/companies with { id: '...', name: 'New' }. There are NO /{id} path segments.\n3. Confirm before ANY write. Before POST/PUT/DELETE: present your plan in business language, then STOP and wait for user to say \"yes\". Do NOT execute the write in the same turn.\n4. Maximum 4 tool calls per message. Hard limit is 10.\n\nYou have 2 tools \u2014 both accept a \"code\" parameter with an async JavaScript arrow function.\n\nTOOL: search \u2014 discover endpoints and schemas (READ-ONLY, fast)\n - spec.findEndpoints(keyword) \u2192 [{ path, methods }]\n - spec.describeEndpoint(path, method) \u2192 COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }\n - spec.describeEntity(keyword) \u2192 { className, fields, relationships }\n\nTOOL: execute \u2014 make API calls (reads and writes)\n - api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }\n\nCOMMON API PATHS (use directly \u2014 do NOT call findEndpoints for these):\n /api/customers/companies \u2014 companies (GET list, POST create, PUT update)\n /api/customers/people \u2014 contacts/people\n /api/customers/deals \u2014 deals/opportunities\n /api/customers/activities \u2014 activities/tasks\n /api/sales/orders \u2014 sales orders\n /api/sales/quotes \u2014 quotes\n /api/sales/invoices \u2014 invoices\n /api/catalog/products \u2014 products\n /api/catalog/categories \u2014 categories\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nRECIPES \u2014 follow EXACTLY for each task type\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nFIND/LIST records (1 call):\n For COMMON PATHS: skip describeEndpoint, go straight to execute.\n 1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })\n The \"search\" query param only matches indexed text fields \u2014 it will NOT match concepts like \"Polish\" or \"large\".\n For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.\n For unknown paths: 1 search + 1 execute.\n\nUPDATE a record (3-4 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') \u2192 learn requestBody fields AND relatedEntity\n 2. execute: GET the record \u2192 find it, get its ID\n 3. execute: PUT to the COLLECTION path with id IN THE BODY:\n api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })\n NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.\n\nCREATE a record (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 gives requiredFields, optionalFields, nestedCollections, and a working example\n 2. Ask user for confirmation with the field values\n 3. execute: api.request({ method: 'POST', ...body })\n If the endpoint has nestedCollections (like lines), include them INLINE in the body \u2014 do NOT create them separately.\n Use the \"example\" from describeEndpoint as your template \u2014 fill in real values.\n Example \u2014 create a quote with line items:\n api.request({ method: 'POST', path: '/api/sales/quotes', body: {\n currencyCode: 'EUR', customerEntityId: '<company-uuid>',\n lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]\n }})\n Do NOT create lines separately. Do NOT include id, quoteId, or total fields \u2014 the server generates them.\n\nCREATE MULTIPLE records (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 learn fields + example\n 2. execute: loop in one call:\n async () => {\n const results = [];\n for (const item of items) {\n results.push(await api.request({ method: 'POST', path: '...', body: item }));\n }\n return results;\n }\n\nDISCOVER (what endpoints/entities exist) (1 call):\n 1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nHARD RULES\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n- MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.\n- NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above \u2014 use them directly with execute.\n- NEVER call describeEntity if describeEndpoint already returned relatedEntity.\n- NEVER repeat a search from earlier in the conversation \u2014 reuse previous results.\n- NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.\n- When you already have the data you need from a previous call, use it \u2014 do NOT fetch more data to \"enrich\" it.\n- Do NOT write JavaScript filters/regex to match records. Fetch data with a simple api.request() call and use YOUR knowledge to interpret the results.\n- The \"search\" query param is fulltext only \u2014 it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.\n- describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template \u2014 fill in real values and send it.\n- For fields you don't know, OMIT them \u2014 the API uses defaults for optional fields.\n- NEVER try to set computed/total fields (amounts, totals, counts) \u2014 the server calculates them.\n- For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.\n- For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.\n\nRESPONSE RULES:\n- Be proactive \u2014 fetch data and present results, don't ask what the user wants to see.\n- Never show technical terms, IDs, JSON, or internal reasoning.\n- Present results in clean business language with **bold names** and bullet points.\n- Only ask for confirmation before create/update/delete operations.\n`.trim()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'AI chat',\n methods: {\n POST: { summary: 'Send message to AI agent via SSE stream' },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\n/**\n * Get user's role IDs from the database.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null\n): Promise<string[]> {\n if (!tenantId) return []\n\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((l) => (l.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\n/**\n * Chat endpoint that routes messages to OpenCode agent.\n * OpenCode connects to MCP server for tool access (search, execute, context_whoami).\n *\n * Emits verbose SSE events for debugging:\n * - thinking: Agent started processing\n * - metadata: Model, tokens, timing info\n * - tool-call: Tool invocation with args\n * - tool-result: Tool response\n * - text: Response text\n * - question: Confirmation question from agent\n * - done: Complete with session ID\n * - error: Error occurred\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { messages, sessionId, answerQuestion } = body as {\n messages?: Array<{ role: string; content: string }>\n sessionId?: string\n // For answering a question\n answerQuestion?: {\n questionId: string\n answer: number\n sessionId: string\n }\n }\n\n // Create SSE stream for frontend compatibility\n const encoder = new TextEncoder()\n const stream = new TransformStream()\n const writer = stream.writable.getWriter()\n let writerClosed = false\n\n const writeSSE = async (event: OpenCodeStreamEvent | { type: string; [key: string]: unknown }) => {\n if (writerClosed) return // Guard against writes after close\n try {\n const jsonStr = JSON.stringify(event)\n await writer.write(encoder.encode(`data: ${jsonStr}\\n\\n`))\n } catch (err) {\n // Writer may have been closed by client disconnect\n console.warn('[AI Chat] Failed to write SSE event:', event.type)\n }\n }\n\n const closeWriter = async () => {\n if (writerClosed) return\n writerClosed = true\n try {\n await writer.close()\n } catch {\n // Already closed\n }\n }\n\n // Resolve a request-scoped EntityManager once \u2014 needed both by the\n // answerQuestion short-circuit (for ownership re-check) and by the\n // streaming branch (for session-token mint + post-`done` binding).\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const opencodeAuth: OpenCodeAuthContext = {\n userId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n }\n\n // Handle question answer - simple JSON response, not SSE\n // The original SSE stream continues and will receive the follow-up response\n if (answerQuestion) {\n try {\n const client = createOpenCodeClient()\n // Resolve the question's actual sessionID from OpenCode \u2014 never\n // trust the caller-supplied `answerQuestion.sessionId` blindly.\n const pending = await client.getPendingQuestions()\n const matching = pending.find((q) => q.id === answerQuestion.questionId)\n if (!matching) {\n // Unknown / stale question id \u2014 same opaque response as a\n // foreign-owner mismatch to avoid leaking which case occurred.\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n if (matching.sessionID !== answerQuestion.sessionId) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n // Look up the api_key row bound to this OpenCode session and\n // assert ownership matches the authenticated caller.\n const ownerRow = await findApiKeyByOpencodeSessionId(em, matching.sessionID)\n if (\n !ownerRow ||\n ownerRow.sessionUserId !== opencodeAuth.userId ||\n (ownerRow.tenantId ?? null) !== opencodeAuth.tenantId ||\n (ownerRow.organizationId ?? null) !== opencodeAuth.organizationId\n ) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n\n await client.answerQuestion(answerQuestion.questionId, answerQuestion.answer)\n return NextResponse.json({ success: true })\n } catch (error) {\n // Fail-closed: any failure during the answerQuestion path \u2014 ownership\n // mismatch, OpenCode unreachable, getPendingQuestions throwing,\n // findApiKeyByOpencodeSessionId throwing \u2014 returns the same opaque\n // 403 envelope used for ownership rejection. This matches finding #1's\n // security intent that the answerQuestion short-circuit MUST NOT leak\n // whether the failure was authorization vs backend connectivity vs\n // implementation error. The real reason is captured in server logs.\n console.error('[AI Chat] Answer error:', error)\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n }\n\n // Handle regular message\n if (!messages || !Array.isArray(messages)) {\n return NextResponse.json({ error: 'messages array is required' }, { status: 400 })\n }\n\n // Get the latest user message\n const lastUserMessage = messages.filter((m) => m.role === 'user').pop()?.content\n if (!lastUserMessage) {\n return NextResponse.json({ error: 'No user message found' }, { status: 400 })\n }\n\n // Bound per-user LLM spend, DB writes (incl. the ephemeral session-key insert\n // below), and SSE buffering. Fail-open so a missing limiter never blocks a turn.\n // Reuses the request container resolved above for the answerQuestion ownership re-check.\n const rateLimited = await checkAiChatRateLimit({\n req,\n container,\n userId: auth.sub,\n tenantId: auth.tenantId,\n })\n if (rateLimited) return rateLimited\n\n const chatStartTime = Date.now()\n const messagePreview = lastUserMessage.slice(0, 80).replace(/\\n/g, ' ')\n console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + '...' : 'new'} message=\"${messagePreview}${lastUserMessage.length > 80 ? '...' : ''}\"`)\n\n\n // For new sessions, create an ephemeral API key that inherits user permissions\n // The API key secret is encrypted and stored; MCP server recovers it via session token\n let sessionToken: string | null = null\n if (!sessionId) {\n try {\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n sessionToken = generateSessionToken()\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: 120,\n })\n console.log('[AI Chat] Created session token:', sessionToken.slice(0, 12) + '...')\n } catch (error) {\n console.error('[AI Chat] Failed to create session key:', error)\n // Continue without session key - tools will use static API key auth\n }\n }\n\n // Build the message to send to OpenCode\n // For NEW sessions: inject system instructions + session token\n // For existing sessions: only inject session token if available\n let messageToSend = ''\n\n // For NEW sessions only, prepend system instructions\n if (!sessionId) {\n messageToSend = `${CHAT_SYSTEM_INSTRUCTIONS}\\n\\n`\n }\n\n // If we have a session token, prepend explicit instructions for the AI to include it in tool calls\n if (sessionToken) {\n messageToSend += `[Session Authorization: ${sessionToken}. Include \"_sessionToken\": \"${sessionToken}\" in EVERY tool call.]\\n\\n`\n }\n\n messageToSend += lastUserMessage\n\n // Process in background - starts AFTER Response is returned so there's a reader for the stream\n ;(async () => {\n let toolCallCount = 0\n let lastTokens: { input?: number; output?: number } | undefined\n let resultSessionId: string | undefined\n\n try {\n // Emit session-authorized event first (if we have a token)\n if (sessionToken) {\n console.log('[AI Chat] Emitting session-authorized event')\n await writeSSE({\n type: 'session-authorized',\n sessionToken: sessionToken.slice(0, 12) + '...',\n })\n }\n\n // Emit thinking event for UX feedback\n await writeSSE({ type: 'thinking' })\n\n // Use streaming handler that supports questions\n await handleOpenCodeMessageStreaming(\n {\n message: messageToSend,\n sessionId,\n auth: opencodeAuth,\n em,\n },\n async (event) => {\n // Track usage from stream events\n if (event.type === 'tool-call') toolCallCount++\n if (event.type === 'metadata' && 'tokens' in event) lastTokens = event.tokens\n if (event.type === 'done' && 'sessionId' in event) {\n resultSessionId = event.sessionId\n // First-time binding: when the caller did NOT supply a\n // sessionId (i.e., this is a freshly minted chat) and we\n // successfully created a session token earlier, bind the\n // newly-resolved OpenCode session id to that api_key row so\n // subsequent resumes can be authorized.\n if (!sessionId && sessionToken && event.sessionId) {\n try {\n await bindOpencodeSessionToApiKey(em, sessionToken, event.sessionId)\n } catch (bindErr) {\n // The response stream is already in-flight \u2014 surface the\n // failure to logs without disturbing the SSE pipeline.\n console.error(\n '[AI Chat] Failed to bind OpenCode session to api_key:',\n bindErr\n )\n }\n }\n }\n\n await writeSSE(event)\n }\n )\n } catch (error) {\n console.error('[AI Chat] OpenCode error:', error)\n await writeSSE({\n type: 'error',\n error: error instanceof Error ? error.message : 'OpenCode request failed',\n })\n } finally {\n const durationMs = Date.now() - chatStartTime\n console.error(`[AI Usage] Chat complete: user=${auth.sub} session=${(resultSessionId || sessionId || 'unknown').slice(0, 16)}... duration=${durationMs}ms toolCalls=${toolCallCount}${lastTokens ? ` tokens={in:${lastTokens.input || 0},out:${lastTokens.output || 0}}` : ''}`)\n await closeWriter()\n }\n })()\n\n return new Response(stream.readable, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n })\n } catch (error) {\n console.error('[AI Chat] Error:', error)\n return NextResponse.json({ error: 'Chat request failed' }, { status: 500 })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkRateLimit,
|
|
3
|
+
RATE_LIMIT_ERROR_KEY,
|
|
4
|
+
RATE_LIMIT_ERROR_FALLBACK
|
|
5
|
+
} from "@open-mercato/shared/lib/ratelimit/helpers";
|
|
6
|
+
import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/config";
|
|
7
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
8
|
+
const AI_CHAT_RATE_LIMIT_DEFAULTS = {
|
|
9
|
+
points: 30,
|
|
10
|
+
duration: 60,
|
|
11
|
+
keyPrefix: "ai_chat"
|
|
12
|
+
};
|
|
13
|
+
async function checkAiChatRateLimit(options) {
|
|
14
|
+
try {
|
|
15
|
+
const isIntegrationTestMode = process.env.OM_TEST_MODE === "1" && process.env.OM_TEST_AI_CHAT_RATE_LIMIT_MODE === "opt-in";
|
|
16
|
+
if (isIntegrationTestMode && options.req.headers.get("x-om-test-rate-limit") !== "on") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
let rateLimiterService = null;
|
|
20
|
+
try {
|
|
21
|
+
rateLimiterService = options.container.resolve("rateLimiterService");
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (!rateLimiterService) return null;
|
|
26
|
+
const config = readEndpointRateLimitConfig("AI_CHAT", AI_CHAT_RATE_LIMIT_DEFAULTS);
|
|
27
|
+
const key = `${options.userId}:${options.tenantId ?? "no-tenant"}`;
|
|
28
|
+
const { translate } = await resolveTranslations();
|
|
29
|
+
const errorMessage = translate(RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK);
|
|
30
|
+
return await checkRateLimit(rateLimiterService, config, key, errorMessage);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
checkAiChatRateLimit
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/lib/rate-limit.ts"],
|
|
4
|
+
"sourcesContent": ["import type { NextResponse } from 'next/server'\nimport {\n checkRateLimit,\n RATE_LIMIT_ERROR_KEY,\n RATE_LIMIT_ERROR_FALLBACK,\n} from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\n\n/** Minimal structural view of the request DI container used to resolve services. */\ninterface ResolvableContainer {\n resolve<T>(name: string): T\n}\n\n/**\n * Per-user / per-tenant rate limit defaults for the AI chat dispatch routes.\n * The AI chat endpoints are the platform's most expensive surface \u2014 each turn\n * runs the full LLM agent loop, performs several per-request DB lookups, persists\n * conversation rows, and buffers the SSE response in memory. A generous default\n * (30 turns/min/user) bounds a misbehaving client without disrupting legitimate\n * multi-turn sessions, and can be tuned via the `RATE_LIMIT_AI_CHAT_*` env vars.\n */\nconst AI_CHAT_RATE_LIMIT_DEFAULTS = {\n points: 30,\n duration: 60,\n keyPrefix: 'ai_chat',\n} as const\n\nexport interface CheckAiChatRateLimitOptions {\n req: Request\n /** Request-scoped DI container; the rate limiter service is resolved from it. */\n container: ResolvableContainer\n userId: string\n tenantId: string | null | undefined\n}\n\n/**\n * Fail-open rate limit check for the AI chat dispatch routes.\n *\n * Resolves the `rateLimiterService` from the request container (the limiter is\n * registered as a DI value in bootstrap; `@open-mercato/core` is not a dependency\n * of this package, so it must come through DI rather than a static import).\n * Keyed on `userId` + `tenantId` so the ceiling is per-user-per-tenant.\n *\n * Returns a 429 `NextResponse` when the bucket is exhausted, or `null` when the\n * request is allowed. Mirrors auth's `checkAuthRateLimit`: when the limiter\n * service is unavailable (not registered, throws, or globally disabled) the\n * request proceeds unthrottled, so the change is non-breaking.\n */\nexport async function checkAiChatRateLimit(\n options: CheckAiChatRateLimitOptions,\n): Promise<NextResponse | null> {\n try {\n // Opt-in test mode mirrors auth's `x-om-test-rate-limit` pattern so suites can\n // exercise the bucket without throttling unrelated requests.\n const isIntegrationTestMode =\n process.env.OM_TEST_MODE === '1' && process.env.OM_TEST_AI_CHAT_RATE_LIMIT_MODE === 'opt-in'\n if (isIntegrationTestMode && options.req.headers.get('x-om-test-rate-limit') !== 'on') {\n return null\n }\n\n let rateLimiterService: RateLimiterService | null = null\n try {\n rateLimiterService = options.container.resolve<RateLimiterService>('rateLimiterService')\n } catch {\n // Limiter not registered in this container \u2014 fail open.\n return null\n }\n if (!rateLimiterService) return null\n\n const config = readEndpointRateLimitConfig('AI_CHAT', AI_CHAT_RATE_LIMIT_DEFAULTS)\n const key = `${options.userId}:${options.tenantId ?? 'no-tenant'}`\n\n const { translate } = await resolveTranslations()\n const errorMessage = translate(RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK)\n\n return await checkRateLimit(rateLimiterService, config, key, errorMessage)\n } catch {\n // Fail open on any unexpected error \u2014 never block a legitimate turn because\n // the limiter misbehaved.\n return null\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC;AAC5C,SAAS,2BAA2B;AAgBpC,MAAM,8BAA8B;AAAA,EAClC,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,WAAW;AACb;AAuBA,eAAsB,qBACpB,SAC8B;AAC9B,MAAI;AAGF,UAAM,wBACJ,QAAQ,IAAI,iBAAiB,OAAO,QAAQ,IAAI,oCAAoC;AACtF,QAAI,yBAAyB,QAAQ,IAAI,QAAQ,IAAI,sBAAsB,MAAM,MAAM;AACrF,aAAO;AAAA,IACT;AAEA,QAAI,qBAAgD;AACpD,QAAI;AACF,2BAAqB,QAAQ,UAAU,QAA4B,oBAAoB;AAAA,IACzF,QAAQ;AAEN,aAAO;AAAA,IACT;AACA,QAAI,CAAC,mBAAoB,QAAO;AAEhC,UAAM,SAAS,4BAA4B,WAAW,2BAA2B;AACjF,UAAM,MAAM,GAAG,QAAQ,MAAM,IAAI,QAAQ,YAAY,WAAW;AAEhE,UAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,UAAM,eAAe,UAAU,sBAAsB,yBAAyB;AAE9E,WAAO,MAAM,eAAe,oBAAoB,QAAQ,KAAK,YAAY;AAAA,EAC3E,QAAQ;AAGN,WAAO;AAAA,EACT;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6290.1.4bb5a8ba3f",
|
|
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.6-develop.
|
|
103
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
102
|
+
"@open-mercato/shared": "0.6.6-develop.6290.1.4bb5a8ba3f",
|
|
103
|
+
"@open-mercato/ui": "0.6.6-develop.6290.1.4bb5a8ba3f",
|
|
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.6-develop.
|
|
110
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
111
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
109
|
+
"@open-mercato/cli": "0.6.6-develop.6290.1.4bb5a8ba3f",
|
|
110
|
+
"@open-mercato/shared": "0.6.6-develop.6290.1.4bb5a8ba3f",
|
|
111
|
+
"@open-mercato/ui": "0.6.6-develop.6290.1.4bb5a8ba3f",
|
|
112
112
|
"@types/react": "^19.2.17",
|
|
113
113
|
"@types/react-dom": "^19.2.3",
|
|
114
114
|
"react": "19.2.7",
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'
|
|
30
30
|
import { AiAgentRuntimeOverrideRepository } from '../../../data/repositories/AiAgentRuntimeOverrideRepository'
|
|
31
31
|
import { createConversationStorage } from '../../../lib/conversation-storage'
|
|
32
|
+
import { checkAiChatRateLimit } from '../../../lib/rate-limit'
|
|
32
33
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
33
34
|
|
|
34
35
|
const MAX_MESSAGES = 100
|
|
@@ -446,6 +447,15 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
446
447
|
await loadAgentRegistry()
|
|
447
448
|
|
|
448
449
|
const container = await createRequestContainer()
|
|
450
|
+
|
|
451
|
+
const rateLimited = await checkAiChatRateLimit({
|
|
452
|
+
req,
|
|
453
|
+
container,
|
|
454
|
+
userId: auth.sub,
|
|
455
|
+
tenantId: auth.tenantId,
|
|
456
|
+
})
|
|
457
|
+
if (rateLimited) return rateLimited
|
|
458
|
+
|
|
449
459
|
const rbacService = container.resolve<RbacService>('rbacService')
|
|
450
460
|
const acl = await rbacService.loadAcl(auth.sub, {
|
|
451
461
|
tenantId: auth.tenantId,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
18
18
|
import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
19
19
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
20
|
+
import { checkAiChatRateLimit } from '../../lib/rate-limit'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* System instructions injected at the start of new chat sessions.
|
|
@@ -286,6 +287,17 @@ export async function POST(req: NextRequest) {
|
|
|
286
287
|
return NextResponse.json({ error: 'No user message found' }, { status: 400 })
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
// Bound per-user LLM spend, DB writes (incl. the ephemeral session-key insert
|
|
291
|
+
// below), and SSE buffering. Fail-open so a missing limiter never blocks a turn.
|
|
292
|
+
// Reuses the request container resolved above for the answerQuestion ownership re-check.
|
|
293
|
+
const rateLimited = await checkAiChatRateLimit({
|
|
294
|
+
req,
|
|
295
|
+
container,
|
|
296
|
+
userId: auth.sub,
|
|
297
|
+
tenantId: auth.tenantId,
|
|
298
|
+
})
|
|
299
|
+
if (rateLimited) return rateLimited
|
|
300
|
+
|
|
289
301
|
const chatStartTime = Date.now()
|
|
290
302
|
const messagePreview = lastUserMessage.slice(0, 80).replace(/\n/g, ' ')
|
|
291
303
|
console.error(`[AI Usage] Chat request: user=${auth.sub} session=${sessionId ? sessionId.slice(0, 16) + '...' : 'new'} message="${messagePreview}${lastUserMessage.length > 80 ? '...' : ''}"`)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
|
|
4
|
+
const mockResolveTranslations = jest.fn()
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
6
|
+
resolveTranslations: (...args: unknown[]) => mockResolveTranslations(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const mockCheckRateLimit = jest.fn()
|
|
10
|
+
jest.mock('@open-mercato/shared/lib/ratelimit/helpers', () => ({
|
|
11
|
+
checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args),
|
|
12
|
+
RATE_LIMIT_ERROR_KEY: 'api.errors.rateLimit',
|
|
13
|
+
RATE_LIMIT_ERROR_FALLBACK: 'Too many requests. Please try again later.',
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const mockReadEndpointRateLimitConfig = jest.fn()
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/ratelimit/config', () => ({
|
|
18
|
+
readEndpointRateLimitConfig: (...args: unknown[]) => mockReadEndpointRateLimitConfig(...args),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import { checkAiChatRateLimit } from '../rate-limit'
|
|
22
|
+
|
|
23
|
+
const config = { points: 30, duration: 60, keyPrefix: 'ai_chat' }
|
|
24
|
+
|
|
25
|
+
function makeRequest(headers?: Record<string, string>): Request {
|
|
26
|
+
return new Request('http://localhost/api/ai_assistant/ai/chat', { method: 'POST', headers })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeContainer(rateLimiterService: unknown, throwOnResolve = false) {
|
|
30
|
+
return {
|
|
31
|
+
resolve<T>(name: string): T {
|
|
32
|
+
if (name !== 'rateLimiterService') throw new Error(`unexpected resolve("${name}")`)
|
|
33
|
+
if (throwOnResolve) throw new Error('not registered')
|
|
34
|
+
return rateLimiterService as T
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ORIGINAL_ENV = { ...process.env }
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
jest.clearAllMocks()
|
|
43
|
+
process.env = { ...ORIGINAL_ENV }
|
|
44
|
+
delete process.env.OM_TEST_MODE
|
|
45
|
+
delete process.env.OM_TEST_AI_CHAT_RATE_LIMIT_MODE
|
|
46
|
+
mockResolveTranslations.mockResolvedValue({
|
|
47
|
+
translate: (_key: string, fallback?: string) => fallback ?? '',
|
|
48
|
+
})
|
|
49
|
+
mockReadEndpointRateLimitConfig.mockReturnValue(config)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
process.env = ORIGINAL_ENV
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('checkAiChatRateLimit', () => {
|
|
57
|
+
it('fails open (returns null) when the limiter service is not registered', async () => {
|
|
58
|
+
const result = await checkAiChatRateLimit({
|
|
59
|
+
req: makeRequest(),
|
|
60
|
+
container: makeContainer(null, true),
|
|
61
|
+
userId: 'user-1',
|
|
62
|
+
tenantId: 'tenant-1',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(result).toBeNull()
|
|
66
|
+
expect(mockCheckRateLimit).not.toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('fails open (returns null) when the resolved service is null', async () => {
|
|
70
|
+
const result = await checkAiChatRateLimit({
|
|
71
|
+
req: makeRequest(),
|
|
72
|
+
container: makeContainer(null),
|
|
73
|
+
userId: 'user-1',
|
|
74
|
+
tenantId: 'tenant-1',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(result).toBeNull()
|
|
78
|
+
expect(mockCheckRateLimit).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns null when the limiter allows the request, keyed on userId + tenantId', async () => {
|
|
82
|
+
mockCheckRateLimit.mockResolvedValue(null)
|
|
83
|
+
|
|
84
|
+
const result = await checkAiChatRateLimit({
|
|
85
|
+
req: makeRequest(),
|
|
86
|
+
container: makeContainer({}),
|
|
87
|
+
userId: 'user-1',
|
|
88
|
+
tenantId: 'tenant-1',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(result).toBeNull()
|
|
92
|
+
expect(mockReadEndpointRateLimitConfig).toHaveBeenCalledWith('AI_CHAT', expect.any(Object))
|
|
93
|
+
expect(mockCheckRateLimit).toHaveBeenCalledWith(
|
|
94
|
+
expect.anything(),
|
|
95
|
+
config,
|
|
96
|
+
'user-1:tenant-1',
|
|
97
|
+
expect.any(String),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns the 429 response when the limiter denies the request', async () => {
|
|
102
|
+
const denied = NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
|
103
|
+
mockCheckRateLimit.mockResolvedValue(denied)
|
|
104
|
+
|
|
105
|
+
const result = await checkAiChatRateLimit({
|
|
106
|
+
req: makeRequest(),
|
|
107
|
+
container: makeContainer({}),
|
|
108
|
+
userId: 'user-2',
|
|
109
|
+
tenantId: 'tenant-2',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(result).toBe(denied)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('uses a stable no-tenant key segment when tenantId is null/undefined', async () => {
|
|
116
|
+
mockCheckRateLimit.mockResolvedValue(null)
|
|
117
|
+
|
|
118
|
+
await checkAiChatRateLimit({
|
|
119
|
+
req: makeRequest(),
|
|
120
|
+
container: makeContainer({}),
|
|
121
|
+
userId: 'user-3',
|
|
122
|
+
tenantId: null,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(mockCheckRateLimit).toHaveBeenCalledWith(
|
|
126
|
+
expect.anything(),
|
|
127
|
+
config,
|
|
128
|
+
'user-3:no-tenant',
|
|
129
|
+
expect.any(String),
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('fails open (returns null) when the limiter throws', async () => {
|
|
134
|
+
mockCheckRateLimit.mockRejectedValue(new Error('Redis connection lost'))
|
|
135
|
+
|
|
136
|
+
const result = await checkAiChatRateLimit({
|
|
137
|
+
req: makeRequest(),
|
|
138
|
+
container: makeContainer({}),
|
|
139
|
+
userId: 'user-1',
|
|
140
|
+
tenantId: 'tenant-1',
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(result).toBeNull()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('opt-in test mode', () => {
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
process.env.OM_TEST_MODE = '1'
|
|
149
|
+
process.env.OM_TEST_AI_CHAT_RATE_LIMIT_MODE = 'opt-in'
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('skips the limiter when the opt-in header is absent', async () => {
|
|
153
|
+
const result = await checkAiChatRateLimit({
|
|
154
|
+
req: makeRequest(),
|
|
155
|
+
container: makeContainer({}),
|
|
156
|
+
userId: 'user-1',
|
|
157
|
+
tenantId: 'tenant-1',
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(result).toBeNull()
|
|
161
|
+
expect(mockCheckRateLimit).not.toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('exercises the limiter when the opt-in header is set', async () => {
|
|
165
|
+
mockCheckRateLimit.mockResolvedValue(null)
|
|
166
|
+
|
|
167
|
+
await checkAiChatRateLimit({
|
|
168
|
+
req: makeRequest({ 'x-om-test-rate-limit': 'on' }),
|
|
169
|
+
container: makeContainer({}),
|
|
170
|
+
userId: 'user-1',
|
|
171
|
+
tenantId: 'tenant-1',
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(mockCheckRateLimit).toHaveBeenCalledTimes(1)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
checkRateLimit,
|
|
4
|
+
RATE_LIMIT_ERROR_KEY,
|
|
5
|
+
RATE_LIMIT_ERROR_FALLBACK,
|
|
6
|
+
} from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
7
|
+
import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
|
|
8
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
9
|
+
import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
|
|
10
|
+
|
|
11
|
+
/** Minimal structural view of the request DI container used to resolve services. */
|
|
12
|
+
interface ResolvableContainer {
|
|
13
|
+
resolve<T>(name: string): T
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Per-user / per-tenant rate limit defaults for the AI chat dispatch routes.
|
|
18
|
+
* The AI chat endpoints are the platform's most expensive surface — each turn
|
|
19
|
+
* runs the full LLM agent loop, performs several per-request DB lookups, persists
|
|
20
|
+
* conversation rows, and buffers the SSE response in memory. A generous default
|
|
21
|
+
* (30 turns/min/user) bounds a misbehaving client without disrupting legitimate
|
|
22
|
+
* multi-turn sessions, and can be tuned via the `RATE_LIMIT_AI_CHAT_*` env vars.
|
|
23
|
+
*/
|
|
24
|
+
const AI_CHAT_RATE_LIMIT_DEFAULTS = {
|
|
25
|
+
points: 30,
|
|
26
|
+
duration: 60,
|
|
27
|
+
keyPrefix: 'ai_chat',
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
export interface CheckAiChatRateLimitOptions {
|
|
31
|
+
req: Request
|
|
32
|
+
/** Request-scoped DI container; the rate limiter service is resolved from it. */
|
|
33
|
+
container: ResolvableContainer
|
|
34
|
+
userId: string
|
|
35
|
+
tenantId: string | null | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fail-open rate limit check for the AI chat dispatch routes.
|
|
40
|
+
*
|
|
41
|
+
* Resolves the `rateLimiterService` from the request container (the limiter is
|
|
42
|
+
* registered as a DI value in bootstrap; `@open-mercato/core` is not a dependency
|
|
43
|
+
* of this package, so it must come through DI rather than a static import).
|
|
44
|
+
* Keyed on `userId` + `tenantId` so the ceiling is per-user-per-tenant.
|
|
45
|
+
*
|
|
46
|
+
* Returns a 429 `NextResponse` when the bucket is exhausted, or `null` when the
|
|
47
|
+
* request is allowed. Mirrors auth's `checkAuthRateLimit`: when the limiter
|
|
48
|
+
* service is unavailable (not registered, throws, or globally disabled) the
|
|
49
|
+
* request proceeds unthrottled, so the change is non-breaking.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkAiChatRateLimit(
|
|
52
|
+
options: CheckAiChatRateLimitOptions,
|
|
53
|
+
): Promise<NextResponse | null> {
|
|
54
|
+
try {
|
|
55
|
+
// Opt-in test mode mirrors auth's `x-om-test-rate-limit` pattern so suites can
|
|
56
|
+
// exercise the bucket without throttling unrelated requests.
|
|
57
|
+
const isIntegrationTestMode =
|
|
58
|
+
process.env.OM_TEST_MODE === '1' && process.env.OM_TEST_AI_CHAT_RATE_LIMIT_MODE === 'opt-in'
|
|
59
|
+
if (isIntegrationTestMode && options.req.headers.get('x-om-test-rate-limit') !== 'on') {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let rateLimiterService: RateLimiterService | null = null
|
|
64
|
+
try {
|
|
65
|
+
rateLimiterService = options.container.resolve<RateLimiterService>('rateLimiterService')
|
|
66
|
+
} catch {
|
|
67
|
+
// Limiter not registered in this container — fail open.
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
if (!rateLimiterService) return null
|
|
71
|
+
|
|
72
|
+
const config = readEndpointRateLimitConfig('AI_CHAT', AI_CHAT_RATE_LIMIT_DEFAULTS)
|
|
73
|
+
const key = `${options.userId}:${options.tenantId ?? 'no-tenant'}`
|
|
74
|
+
|
|
75
|
+
const { translate } = await resolveTranslations()
|
|
76
|
+
const errorMessage = translate(RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK)
|
|
77
|
+
|
|
78
|
+
return await checkRateLimit(rateLimiterService, config, key, errorMessage)
|
|
79
|
+
} catch {
|
|
80
|
+
// Fail open on any unexpected error — never block a legitimate turn because
|
|
81
|
+
// the limiter misbehaved.
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|