@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +30 -4
- package/dist/frontend/components/AiChatButton.js +3 -2
- package/dist/frontend/components/AiChatButton.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +4 -3
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
- package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +12 -0
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
- package/dist/modules/ai_assistant/data/entities.js +177 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
- package/dist/modules/ai_assistant/events.js +8 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +74 -1
- package/dist/modules/ai_assistant/i18n/en.json +74 -1
- package/dist/modules/ai_assistant/i18n/es.json +75 -2
- package/dist/modules/ai_assistant/i18n/pl.json +74 -1
- package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
- package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
- package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +34 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
- package/generated/entities/ai_token_usage_daily/index.ts +16 -0
- package/generated/entities/ai_token_usage_event/index.ts +19 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +47 -1
- package/package.json +15 -7
- package/src/frontend/components/AiChatButton.tsx +3 -2
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
- package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
- package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
- package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
- package/src/modules/ai_assistant/api/settings/route.ts +5 -3
- package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
- package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
- package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
- package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/data/entities.ts +237 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
- package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
- package/src/modules/ai_assistant/events.ts +8 -0
- package/src/modules/ai_assistant/i18n/de.json +74 -1
- package/src/modules/ai_assistant/i18n/en.json +74 -1
- package/src/modules/ai_assistant/i18n/es.json +75 -2
- package/src/modules/ai_assistant/i18n/pl.json +74 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
- package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
- package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
- package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
- package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
- package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
- package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
- package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
- package/src/modules/ai_assistant/setup.ts +49 -0
- package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
- 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
|
+
}
|