@open-mercato/ai-assistant 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +30 -4
  3. package/dist/frontend/components/AiChatButton.js +3 -2
  4. package/dist/frontend/components/AiChatButton.js.map +2 -2
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
  12. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
  14. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
  16. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/api/settings/route.js +4 -3
  18. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  19. package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
  20. package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
  21. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
  22. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
  23. package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
  24. package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
  33. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
  34. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
  35. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
  36. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
  37. package/dist/modules/ai_assistant/cli.js +12 -0
  38. package/dist/modules/ai_assistant/cli.js.map +2 -2
  39. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
  40. package/dist/modules/ai_assistant/data/entities.js +177 -1
  41. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
  43. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  44. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
  45. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
  46. package/dist/modules/ai_assistant/events.js +8 -0
  47. package/dist/modules/ai_assistant/events.js.map +2 -2
  48. package/dist/modules/ai_assistant/i18n/de.json +74 -1
  49. package/dist/modules/ai_assistant/i18n/en.json +74 -1
  50. package/dist/modules/ai_assistant/i18n/es.json +75 -2
  51. package/dist/modules/ai_assistant/i18n/pl.json +74 -1
  52. package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
  55. package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
  56. package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
  59. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
  61. package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
  62. package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
  63. package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
  64. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
  65. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
  66. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
  67. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
  68. package/dist/modules/ai_assistant/setup.js +34 -0
  69. package/dist/modules/ai_assistant/setup.js.map +2 -2
  70. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
  71. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
  72. package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
  73. package/generated/entities/ai_token_usage_daily/index.ts +16 -0
  74. package/generated/entities/ai_token_usage_event/index.ts +19 -0
  75. package/generated/entities.ids.generated.ts +2 -0
  76. package/generated/entity-fields-registry.ts +47 -1
  77. package/package.json +15 -7
  78. package/src/frontend/components/AiChatButton.tsx +3 -2
  79. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
  81. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
  82. package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
  83. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
  84. package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
  85. package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
  86. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
  87. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
  88. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
  89. package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
  90. package/src/modules/ai_assistant/api/settings/route.ts +5 -3
  91. package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
  92. package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
  93. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
  94. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
  95. package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
  96. package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
  99. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
  100. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
  101. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
  102. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
  103. package/src/modules/ai_assistant/cli.ts +18 -0
  104. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
  105. package/src/modules/ai_assistant/data/entities.ts +237 -0
  106. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
  107. package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
  108. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
  109. package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
  110. package/src/modules/ai_assistant/events.ts +8 -0
  111. package/src/modules/ai_assistant/i18n/de.json +74 -1
  112. package/src/modules/ai_assistant/i18n/en.json +74 -1
  113. package/src/modules/ai_assistant/i18n/es.json +75 -2
  114. package/src/modules/ai_assistant/i18n/pl.json +74 -1
  115. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
  116. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
  117. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
  118. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
  119. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
  120. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
  121. package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
  122. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
  123. package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
  124. package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
  125. package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
  126. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
  127. package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
  128. package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
  129. package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
  131. package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
  133. package/src/modules/ai_assistant/setup.ts +49 -0
  134. package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
  135. package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
@@ -0,0 +1,130 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import { AiTokenUsageRepository } from '../../../../data/repositories/AiTokenUsageRepository'
9
+ import { hasRequiredFeatures } from '../../../../lib/auth'
10
+ import { toInteger, toIsoString } from '../../../../lib/usage-serialization'
11
+
12
+ const REQUIRED_FEATURE = 'ai_assistant.settings.manage'
13
+
14
+ const sessionIdParamSchema = z.object({
15
+ sessionId: z
16
+ .string()
17
+ .trim()
18
+ .uuid('sessionId must be a valid UUID'),
19
+ })
20
+
21
+ export const openApi: OpenApiRouteDoc = {
22
+ tag: 'AI Assistant',
23
+ summary: 'Per-step token usage events for a session',
24
+ methods: {
25
+ GET: {
26
+ operationId: 'aiAssistantUsageSessionDetail',
27
+ summary: 'Fetch per-step token usage event rows for a single session.',
28
+ description:
29
+ 'Returns up to 200 raw `ai_token_usage_events` rows for the given `sessionId`, ' +
30
+ 'ordered by `created_at ASC, step_index ASC`. Tenant-scoped. ' +
31
+ 'Requires `ai_assistant.settings.manage`.',
32
+ responses: [
33
+ {
34
+ status: 200,
35
+ description: 'Array of per-step event rows for the session.',
36
+ mediaType: 'application/json',
37
+ },
38
+ ],
39
+ errors: [
40
+ { status: 400, description: 'Invalid session id (must be a UUID).' },
41
+ { status: 401, description: 'Unauthenticated caller.' },
42
+ { status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
43
+ { status: 404, description: 'No events found for the given session id in the caller\'s tenant.' },
44
+ { status: 500, description: 'Internal failure.' },
45
+ ],
46
+ },
47
+ },
48
+ }
49
+
50
+ export const metadata = {
51
+ path: '/ai_assistant/usage/sessions/[sessionId]',
52
+ GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
53
+ }
54
+
55
+ interface RouteContext {
56
+ params: Promise<{ sessionId: string }>
57
+ }
58
+
59
+ function jsonError(status: number, message: string, code: string, extra?: Record<string, unknown>): NextResponse {
60
+ return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
61
+ }
62
+
63
+ export async function GET(req: NextRequest, context: RouteContext): Promise<Response> {
64
+ const auth = await getAuthFromRequest(req)
65
+ if (!auth) {
66
+ return jsonError(401, 'Unauthorized', 'unauthenticated')
67
+ }
68
+
69
+ const rawParams = await context.params
70
+ const paramResult = sessionIdParamSchema.safeParse(rawParams)
71
+ if (!paramResult.success) {
72
+ return jsonError(400, 'Invalid session id.', 'validation_error', {
73
+ issues: paramResult.error.issues,
74
+ })
75
+ }
76
+
77
+ const { sessionId } = paramResult.data
78
+
79
+ try {
80
+ const container = await createRequestContainer()
81
+ const rbacService = container.resolve<RbacService>('rbacService')
82
+ const acl = await rbacService.loadAcl(auth.sub, {
83
+ tenantId: auth.tenantId,
84
+ organizationId: auth.orgId,
85
+ })
86
+
87
+ if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
88
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
89
+ }
90
+
91
+ if (!auth.tenantId) {
92
+ return jsonError(404, `No events found for session "${sessionId}".`, 'session_not_found')
93
+ }
94
+
95
+ const em = container.resolve<EntityManager>('em')
96
+ const repo = new AiTokenUsageRepository(em)
97
+ const events = await repo.listEventsForSession(auth.tenantId, sessionId)
98
+
99
+ if (events.length === 0) {
100
+ return jsonError(404, `No events found for session "${sessionId}".`, 'session_not_found')
101
+ }
102
+
103
+ const serialized = events.map((event) => ({
104
+ id: event.id,
105
+ tenantId: event.tenantId,
106
+ organizationId: event.organizationId ?? null,
107
+ userId: event.userId,
108
+ agentId: event.agentId,
109
+ moduleId: event.moduleId,
110
+ sessionId: event.sessionId,
111
+ turnId: event.turnId,
112
+ stepIndex: toInteger(event.stepIndex),
113
+ providerId: event.providerId,
114
+ modelId: event.modelId,
115
+ inputTokens: toInteger(event.inputTokens),
116
+ outputTokens: toInteger(event.outputTokens),
117
+ cachedInputTokens: event.cachedInputTokens == null ? null : toInteger(event.cachedInputTokens),
118
+ reasoningTokens: event.reasoningTokens == null ? null : toInteger(event.reasoningTokens),
119
+ finishReason: event.finishReason ?? null,
120
+ loopAbortReason: event.loopAbortReason ?? null,
121
+ createdAt: toIsoString(event.createdAt),
122
+ updatedAt: toIsoString(event.updatedAt),
123
+ }))
124
+
125
+ return NextResponse.json({ events: serialized, total: serialized.length, sessionId })
126
+ } catch (error) {
127
+ console.error('[AI Usage Session Detail] GET error:', error)
128
+ return jsonError(500, 'Failed to fetch session event data.', 'internal_error')
129
+ }
130
+ }
@@ -0,0 +1,123 @@
1
+ const authMock = jest.fn()
2
+ const loadAclMock = jest.fn()
3
+ const createRequestContainerMock = jest.fn()
4
+ const executeMock = jest.fn()
5
+
6
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
7
+ getAuthFromRequest: (...args: unknown[]) => authMock(...args),
8
+ }))
9
+
10
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
11
+ createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
12
+ }))
13
+
14
+ import { GET } from '../route'
15
+
16
+ function buildRequest(params: Record<string, string> = {}) {
17
+ const url = new URL('http://localhost/api/ai_assistant/usage/sessions')
18
+ for (const [k, v] of Object.entries(params)) {
19
+ url.searchParams.set(k, v)
20
+ }
21
+ return new Request(url.toString(), { method: 'GET' })
22
+ }
23
+
24
+ function defaultParams(overrides: Record<string, string> = {}) {
25
+ return {
26
+ from: '2026-05-01',
27
+ to: '2026-05-31',
28
+ ...overrides,
29
+ }
30
+ }
31
+
32
+ function makeSessionRow(overrides: Record<string, unknown> = {}) {
33
+ return {
34
+ session_id: '11111111-1111-4111-8111-111111111111',
35
+ agent_id: 'catalog.assistant',
36
+ module_id: 'catalog',
37
+ user_id: '22222222-2222-4222-8222-222222222222',
38
+ started_at: '2026-05-01T12:00:00.000Z',
39
+ last_event_at: '2026-05-01T12:30:00.000Z',
40
+ step_count: 5n,
41
+ turn_count: 3n,
42
+ input_tokens: 1000n,
43
+ output_tokens: 500n,
44
+ cached_input_tokens: 10n,
45
+ reasoning_tokens: 20n,
46
+ ...overrides,
47
+ }
48
+ }
49
+
50
+ describe('GET /api/ai_assistant/usage/sessions', () => {
51
+ let consoleErrorSpy: jest.SpyInstance
52
+
53
+ beforeEach(() => {
54
+ jest.clearAllMocks()
55
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
56
+ authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: null })
57
+ loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
58
+ executeMock
59
+ .mockResolvedValueOnce([{ total: 1n }])
60
+ .mockResolvedValueOnce([makeSessionRow()])
61
+ createRequestContainerMock.mockResolvedValue({
62
+ resolve: (name: string) => {
63
+ if (name === 'rbacService') return { loadAcl: loadAclMock, hasAllFeatures: (req: string[], have: string[]) => req.every((r) => have.includes(r)) }
64
+ if (name === 'em') return { getConnection: () => ({ execute: executeMock }) }
65
+ throw new Error(`Unknown token: ${name}`)
66
+ },
67
+ })
68
+ })
69
+
70
+ afterEach(() => {
71
+ consoleErrorSpy.mockRestore()
72
+ })
73
+
74
+ it('returns 401 when unauthenticated', async () => {
75
+ authMock.mockResolvedValue(null)
76
+ const res = await GET(buildRequest(defaultParams()) as Parameters<typeof GET>[0])
77
+ expect(res.status).toBe(401)
78
+ })
79
+
80
+ it('returns 403 when caller lacks ai_assistant.settings.manage', async () => {
81
+ loadAclMock.mockResolvedValue({ features: ['ai_assistant.view'], isSuperAdmin: false })
82
+ const res = await GET(buildRequest(defaultParams()) as Parameters<typeof GET>[0])
83
+ expect(res.status).toBe(403)
84
+ })
85
+
86
+ it('returns 400 when from is missing', async () => {
87
+ const res = await GET(buildRequest({ to: '2026-05-31' }) as Parameters<typeof GET>[0])
88
+ expect(res.status).toBe(400)
89
+ const body = await res.json()
90
+ expect(body.code).toBe('validation_error')
91
+ })
92
+
93
+ it('serializes bigint aggregates and string timestamps returned by the database driver', async () => {
94
+ const res = await GET(buildRequest(defaultParams({ limit: '50', offset: '0' })) as Parameters<typeof GET>[0])
95
+
96
+ expect(res.status).toBe(200)
97
+ const body = await res.json()
98
+ expect(body.total).toBe(1)
99
+ expect(body.sessions).toHaveLength(1)
100
+ expect(body.sessions[0]).toMatchObject({
101
+ sessionId: '11111111-1111-4111-8111-111111111111',
102
+ agentId: 'catalog.assistant',
103
+ moduleId: 'catalog',
104
+ userId: '22222222-2222-4222-8222-222222222222',
105
+ startedAt: '2026-05-01T12:00:00.000Z',
106
+ lastEventAt: '2026-05-01T12:30:00.000Z',
107
+ stepCount: 5,
108
+ turnCount: 3,
109
+ inputTokens: 1000,
110
+ outputTokens: 500,
111
+ cachedInputTokens: 10,
112
+ reasoningTokens: 20,
113
+ })
114
+ })
115
+
116
+ it('passes the agent filter to the aggregate queries', async () => {
117
+ const res = await GET(buildRequest(defaultParams({ agentId: 'catalog.assistant' })) as Parameters<typeof GET>[0])
118
+
119
+ expect(res.status).toBe(200)
120
+ expect(executeMock.mock.calls[0][1]).toEqual(['tenant-1', '2026-05-01', '2026-05-31', 'catalog.assistant'])
121
+ expect(executeMock.mock.calls[1][1]).toEqual(['tenant-1', '2026-05-01', '2026-05-31', 'catalog.assistant', 100, 0])
122
+ })
123
+ })
@@ -0,0 +1,184 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import { hasRequiredFeatures } from '../../../lib/auth'
9
+ import { toInteger, toIsoString } from '../../../lib/usage-serialization'
10
+
11
+ const REQUIRED_FEATURE = 'ai_assistant.settings.manage'
12
+
13
+ const MAX_PAGE_SIZE = 100
14
+
15
+ const querySchema = z.object({
16
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be a date in YYYY-MM-DD format'),
17
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be a date in YYYY-MM-DD format'),
18
+ agentId: z.string().min(1).max(256).optional(),
19
+ limit: z
20
+ .string()
21
+ .optional()
22
+ .transform((val) => (val !== undefined ? parseInt(val, 10) : MAX_PAGE_SIZE))
23
+ .refine((val) => !isNaN(val) && val > 0 && val <= MAX_PAGE_SIZE, {
24
+ message: `limit must be between 1 and ${MAX_PAGE_SIZE}`,
25
+ }),
26
+ offset: z
27
+ .string()
28
+ .optional()
29
+ .transform((val) => (val !== undefined ? parseInt(val, 10) : 0))
30
+ .refine((val) => !isNaN(val) && val >= 0, { message: 'offset must be a non-negative integer' }),
31
+ })
32
+
33
+ export const openApi: OpenApiRouteDoc = {
34
+ tag: 'AI Assistant',
35
+ summary: 'Per-session token usage totals',
36
+ methods: {
37
+ GET: {
38
+ operationId: 'aiAssistantUsageSessions',
39
+ summary: 'List per-session token usage totals for a date window.',
40
+ description:
41
+ 'Returns aggregated token-usage data grouped by `session_id` from `ai_token_usage_events` ' +
42
+ 'for the given date window. Tenant-scoped. Optionally filtered by `agentId`. ' +
43
+ 'Paginated via `limit` / `offset`. Requires `ai_assistant.settings.manage`.',
44
+ query: querySchema,
45
+ responses: [
46
+ {
47
+ status: 200,
48
+ description: 'Array of session-level usage summaries plus pagination metadata.',
49
+ mediaType: 'application/json',
50
+ },
51
+ ],
52
+ errors: [
53
+ { status: 400, description: 'Invalid query parameters.' },
54
+ { status: 401, description: 'Unauthenticated caller.' },
55
+ { status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
56
+ { status: 500, description: 'Internal failure.' },
57
+ ],
58
+ },
59
+ },
60
+ }
61
+
62
+ export const metadata = {
63
+ path: '/ai_assistant/usage/sessions',
64
+ GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
65
+ }
66
+
67
+ function jsonError(status: number, message: string, code: string, extra?: Record<string, unknown>): NextResponse {
68
+ return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
69
+ }
70
+
71
+ export async function GET(req: NextRequest): Promise<Response> {
72
+ const auth = await getAuthFromRequest(req)
73
+ if (!auth) {
74
+ return jsonError(401, 'Unauthorized', 'unauthenticated')
75
+ }
76
+
77
+ const { searchParams } = new URL(req.url)
78
+ const rawQuery = {
79
+ from: searchParams.get('from') ?? undefined,
80
+ to: searchParams.get('to') ?? undefined,
81
+ agentId: searchParams.get('agentId') ?? undefined,
82
+ limit: searchParams.get('limit') ?? undefined,
83
+ offset: searchParams.get('offset') ?? undefined,
84
+ }
85
+
86
+ const queryResult = querySchema.safeParse(rawQuery)
87
+ if (!queryResult.success) {
88
+ return jsonError(400, 'Invalid query parameters.', 'validation_error', {
89
+ issues: queryResult.error.issues,
90
+ })
91
+ }
92
+
93
+ const { from, to, agentId, limit, offset } = queryResult.data
94
+
95
+ try {
96
+ const container = await createRequestContainer()
97
+ const rbacService = container.resolve<RbacService>('rbacService')
98
+ const acl = await rbacService.loadAcl(auth.sub, {
99
+ tenantId: auth.tenantId,
100
+ organizationId: auth.orgId,
101
+ })
102
+
103
+ if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
104
+ return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
105
+ }
106
+
107
+ if (!auth.tenantId) {
108
+ return NextResponse.json({ sessions: [], total: 0, limit, offset })
109
+ }
110
+
111
+ const em = container.resolve<EntityManager>('em')
112
+ const connection = em.getConnection()
113
+
114
+ const params: unknown[] = [auth.tenantId, from, to]
115
+ let agentFilter = ''
116
+ if (agentId) {
117
+ agentFilter = 'and agent_id = ?'
118
+ params.push(agentId)
119
+ }
120
+
121
+ const countParams = [...params]
122
+ const countSql = `
123
+ select count(distinct session_id)::bigint as total
124
+ from ai_token_usage_events
125
+ where tenant_id = ?
126
+ and created_at >= ?::date
127
+ and created_at < (?::date + interval '1 day')
128
+ ${agentFilter}
129
+ `
130
+ const countRows = await connection.execute(countSql, countParams, 'all')
131
+ const totalRaw = Array.isArray(countRows) && countRows.length > 0
132
+ ? (countRows[0] as Record<string, unknown>).total
133
+ : '0'
134
+ const total = toInteger(totalRaw)
135
+
136
+ params.push(limit, offset)
137
+ const dataSql = `
138
+ select
139
+ session_id,
140
+ agent_id,
141
+ module_id,
142
+ user_id,
143
+ min(created_at) as started_at,
144
+ max(created_at) as last_event_at,
145
+ count(*)::bigint as step_count,
146
+ count(distinct turn_id)::bigint as turn_count,
147
+ sum(input_tokens)::bigint as input_tokens,
148
+ sum(output_tokens)::bigint as output_tokens,
149
+ sum(coalesce(cached_input_tokens, 0))::bigint as cached_input_tokens,
150
+ sum(coalesce(reasoning_tokens, 0))::bigint as reasoning_tokens
151
+ from ai_token_usage_events
152
+ where tenant_id = ?
153
+ and created_at >= ?::date
154
+ and created_at < (?::date + interval '1 day')
155
+ ${agentFilter}
156
+ group by session_id, agent_id, module_id, user_id
157
+ order by started_at desc
158
+ limit ? offset ?
159
+ `
160
+ const dataRows = await connection.execute(dataSql, params, 'all')
161
+
162
+ const sessions = Array.isArray(dataRows)
163
+ ? (dataRows as Array<Record<string, unknown>>).map((row) => ({
164
+ sessionId: row.session_id as string,
165
+ agentId: row.agent_id as string,
166
+ moduleId: row.module_id as string,
167
+ userId: row.user_id as string,
168
+ startedAt: toIsoString(row.started_at),
169
+ lastEventAt: toIsoString(row.last_event_at),
170
+ stepCount: toInteger(row.step_count),
171
+ turnCount: toInteger(row.turn_count),
172
+ inputTokens: toInteger(row.input_tokens),
173
+ outputTokens: toInteger(row.output_tokens),
174
+ cachedInputTokens: toInteger(row.cached_input_tokens),
175
+ reasoningTokens: toInteger(row.reasoning_tokens),
176
+ }))
177
+ : []
178
+
179
+ return NextResponse.json({ sessions, total, limit, offset })
180
+ } catch (error) {
181
+ console.error('[AI Usage Sessions] GET error:', error)
182
+ return jsonError(500, 'Failed to fetch session usage data.', 'internal_error')
183
+ }
184
+ }