@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
|
@@ -8,7 +8,11 @@ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacS
|
|
|
8
8
|
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
9
9
|
import { loadAgentRegistry } from '../../../lib/agent-registry'
|
|
10
10
|
import { checkAgentPolicy, type AgentPolicyDenyCode } from '../../../lib/agent-policy'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
runAiAgentText,
|
|
13
|
+
resolveLoopBudgetPreset,
|
|
14
|
+
type AiAgentLoopBudgetPreset,
|
|
15
|
+
} from '../../../lib/agent-runtime'
|
|
12
16
|
import { AgentPolicyError } from '../../../lib/agent-tools'
|
|
13
17
|
import { readBaseurlAllowlist, isBaseurlAllowlisted } from '../../../lib/baseurl-allowlist'
|
|
14
18
|
import {
|
|
@@ -52,10 +56,13 @@ const chatRequestSchema = z.object({
|
|
|
52
56
|
debug: z.boolean().optional(),
|
|
53
57
|
pageContext: pageContextSchema.optional(),
|
|
54
58
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
+
* Stable per-conversation id (Phase 6.2). Wins over `conversationId` when
|
|
60
|
+
* both are provided. The server echoes the resolved id on the SSE
|
|
61
|
+
* `loop-finish` event so clients can persist it for the next turn.
|
|
62
|
+
*/
|
|
63
|
+
sessionId: z.string().uuid().optional(),
|
|
64
|
+
/**
|
|
65
|
+
* @deprecated Use `sessionId` instead.
|
|
59
66
|
*/
|
|
60
67
|
conversationId: z.string().min(1).max(128).optional(),
|
|
61
68
|
})
|
|
@@ -69,7 +76,7 @@ const agentQuerySchema = z.object({
|
|
|
69
76
|
/**
|
|
70
77
|
* Per-request provider override. Must match a registered + configured
|
|
71
78
|
* provider id. Validated against `llmProviderRegistry` at dispatch time.
|
|
72
|
-
* Rejected when the agent has `
|
|
79
|
+
* Rejected when the agent has `allowRuntimeOverride: false`.
|
|
73
80
|
*
|
|
74
81
|
* Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
|
|
75
82
|
*/
|
|
@@ -89,6 +96,18 @@ const agentQuerySchema = z.object({
|
|
|
89
96
|
* Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
|
|
90
97
|
*/
|
|
91
98
|
baseUrl: z.string().optional(),
|
|
99
|
+
/**
|
|
100
|
+
* Named loop-budget preset. Maps to a fixed `loop.budget` triple:
|
|
101
|
+
* tight → maxSteps: 3, maxWallClockMs: 10_000, maxTokens: 50_000
|
|
102
|
+
* default → no override (agent default applies)
|
|
103
|
+
* loose → maxSteps: 20, maxWallClockMs: 120_000, maxTokens: 500_000
|
|
104
|
+
*
|
|
105
|
+
* Rejected when the agent has `allowRuntimeOverride: false` or
|
|
106
|
+
* `loop.allowRuntimeOverride: false`.
|
|
107
|
+
*
|
|
108
|
+
* Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
109
|
+
*/
|
|
110
|
+
loopBudget: z.enum(['tight', 'default', 'loose']).optional(),
|
|
92
111
|
})
|
|
93
112
|
|
|
94
113
|
export const openApi: OpenApiRouteDoc = {
|
|
@@ -107,7 +126,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
107
126
|
'override the resolved provider/model/base-URL for this turn (Phase 4a). ' +
|
|
108
127
|
'Provider must be registered and configured; baseUrl must match ' +
|
|
109
128
|
'`AI_RUNTIME_BASEURL_ALLOWLIST` when set. Both are suppressed when the ' +
|
|
110
|
-
'agent declares `
|
|
129
|
+
'agent declares `allowRuntimeOverride: false`.',
|
|
111
130
|
query: agentQuerySchema,
|
|
112
131
|
requestBody: {
|
|
113
132
|
contentType: 'application/json',
|
|
@@ -122,7 +141,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
122
141
|
status: 400,
|
|
123
142
|
description:
|
|
124
143
|
'Invalid query param, malformed payload, or message count above the cap. ' +
|
|
125
|
-
'Typed codes: `runtime_override_disabled` (agent has
|
|
144
|
+
'Typed codes: `runtime_override_disabled` (agent has allowRuntimeOverride:false), ' +
|
|
126
145
|
'`provider_unknown` (provider id not registered), ' +
|
|
127
146
|
'`provider_not_configured` (provider registered but no API key in env), ' +
|
|
128
147
|
'`baseurl_not_allowlisted` (baseUrl not in AI_RUNTIME_BASEURL_ALLOWLIST).',
|
|
@@ -182,6 +201,7 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
182
201
|
provider: requestUrl.searchParams.get('provider') ?? undefined,
|
|
183
202
|
model: requestUrl.searchParams.get('model') ?? undefined,
|
|
184
203
|
baseUrl: requestUrl.searchParams.get('baseUrl') ?? undefined,
|
|
204
|
+
loopBudget: requestUrl.searchParams.get('loopBudget') ?? undefined,
|
|
185
205
|
})
|
|
186
206
|
if (!queryResult.success) {
|
|
187
207
|
return jsonError(400, 'Invalid or missing "agent" query parameter.', 'validation_error', {
|
|
@@ -192,6 +212,7 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
192
212
|
const rawProvider = queryResult.data.provider
|
|
193
213
|
const rawModel = queryResult.data.model
|
|
194
214
|
const rawBaseUrl = queryResult.data.baseUrl
|
|
215
|
+
const rawLoopBudget = queryResult.data.loopBudget as AiAgentLoopBudgetPreset | undefined
|
|
195
216
|
|
|
196
217
|
let parsedBody: unknown
|
|
197
218
|
try {
|
|
@@ -241,16 +262,23 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
241
262
|
const hasRuntimeOverride =
|
|
242
263
|
(rawProvider && rawProvider.trim().length > 0) ||
|
|
243
264
|
(rawModel && rawModel.trim().length > 0) ||
|
|
244
|
-
(rawBaseUrl && rawBaseUrl.trim().length > 0)
|
|
265
|
+
(rawBaseUrl && rawBaseUrl.trim().length > 0) ||
|
|
266
|
+
(rawLoopBudget !== undefined && rawLoopBudget !== 'default')
|
|
245
267
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
268
|
+
// `allowRuntimeOverride` is the canonical flag (renamed from
|
|
269
|
+
// `allowRuntimeModelOverride` in Phase 4 of this spec). Both are checked
|
|
270
|
+
// here to cover agents declared before the rename lands; the deprecated
|
|
271
|
+
// alias has lower priority.
|
|
272
|
+
const runtimeOverrideAllowed =
|
|
273
|
+
agentDef.allowRuntimeOverride !== false &&
|
|
274
|
+
agentDef.allowRuntimeModelOverride !== false
|
|
275
|
+
|
|
276
|
+
if (hasRuntimeOverride && !runtimeOverrideAllowed) {
|
|
277
|
+
return jsonError(
|
|
278
|
+
400,
|
|
279
|
+
`Agent "${agentId}" has runtime override disabled (allowRuntimeOverride: false).`,
|
|
280
|
+
'runtime_override_disabled',
|
|
281
|
+
)
|
|
254
282
|
}
|
|
255
283
|
|
|
256
284
|
let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null
|
|
@@ -374,7 +402,7 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
374
402
|
)
|
|
375
403
|
}
|
|
376
404
|
}
|
|
377
|
-
// --- end Phase 4a validation ---
|
|
405
|
+
// --- end Phase 4a + Phase 4 validation ---
|
|
378
406
|
|
|
379
407
|
const requestOverride =
|
|
380
408
|
hasRuntimeOverride
|
|
@@ -385,12 +413,19 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
385
413
|
}
|
|
386
414
|
: undefined
|
|
387
415
|
|
|
416
|
+
// Resolve the loopBudget preset to a loop config override (Phase 4).
|
|
417
|
+
const loopFromPreset =
|
|
418
|
+
rawLoopBudget !== undefined && rawLoopBudget !== 'default'
|
|
419
|
+
? resolveLoopBudgetPreset(rawLoopBudget)
|
|
420
|
+
: undefined
|
|
421
|
+
|
|
388
422
|
return await runAiAgentText({
|
|
389
423
|
agentId,
|
|
390
424
|
messages: bodyResult.data.messages as unknown as UIMessage[],
|
|
391
425
|
attachmentIds: bodyResult.data.attachmentIds,
|
|
392
426
|
pageContext: bodyResult.data.pageContext,
|
|
393
427
|
debug: bodyResult.data.debug,
|
|
428
|
+
sessionId: bodyResult.data.sessionId ?? null,
|
|
394
429
|
conversationId: bodyResult.data.conversationId ?? null,
|
|
395
430
|
authContext: {
|
|
396
431
|
tenantId: auth.tenantId ?? null,
|
|
@@ -401,6 +436,8 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
401
436
|
},
|
|
402
437
|
container,
|
|
403
438
|
requestOverride,
|
|
439
|
+
loop: loopFromPreset,
|
|
440
|
+
emitLoopTrace: true,
|
|
404
441
|
})
|
|
405
442
|
} catch (error) {
|
|
406
443
|
if (error instanceof AgentPolicyError) {
|
|
@@ -16,7 +16,7 @@ import { AiAgentRuntimeOverrideRepository, AiAgentRuntimeOverrideValidationError
|
|
|
16
16
|
import { AiTenantModelAllowlistRepository } from '../../data/repositories/AiTenantModelAllowlistRepository'
|
|
17
17
|
import { isBaseurlAllowlisted, readBaseurlAllowlist } from '../../lib/baseurl-allowlist'
|
|
18
18
|
import { loadAgentRegistry, listAgents } from '../../lib/agent-registry'
|
|
19
|
-
import { createModelFactory } from '../../lib/model-factory'
|
|
19
|
+
import { createModelFactory, resolveAllowRuntimeOverride } from '../../lib/model-factory'
|
|
20
20
|
import {
|
|
21
21
|
agentOverrideModelAllowlistEnvVarName,
|
|
22
22
|
agentOverrideProviderAllowlistEnvVarName,
|
|
@@ -181,6 +181,7 @@ export async function GET(req: NextRequest) {
|
|
|
181
181
|
let agentResolutions: Array<{
|
|
182
182
|
agentId: string
|
|
183
183
|
moduleId: string
|
|
184
|
+
allowRuntimeOverride: boolean
|
|
184
185
|
allowRuntimeModelOverride: boolean
|
|
185
186
|
codeDefaultProviderId: string | null
|
|
186
187
|
codeDefaultModelId: string | null
|
|
@@ -273,7 +274,7 @@ export async function GET(req: NextRequest) {
|
|
|
273
274
|
agentDefaultModel: agent.defaultModel,
|
|
274
275
|
agentDefaultProvider: agent.defaultProvider,
|
|
275
276
|
agentDefaultBaseUrl: agent.defaultBaseUrl,
|
|
276
|
-
|
|
277
|
+
allowRuntimeOverride: resolveAllowRuntimeOverride(agent),
|
|
277
278
|
tenantOverride: agentTenantOverride,
|
|
278
279
|
tenantAllowlist: tenantAllowlistSnapshot,
|
|
279
280
|
})
|
|
@@ -311,7 +312,8 @@ export async function GET(req: NextRequest) {
|
|
|
311
312
|
return {
|
|
312
313
|
agentId: agent.id,
|
|
313
314
|
moduleId: agent.moduleId,
|
|
314
|
-
|
|
315
|
+
allowRuntimeOverride: resolveAllowRuntimeOverride(agent),
|
|
316
|
+
allowRuntimeModelOverride: resolveAllowRuntimeOverride(agent),
|
|
315
317
|
codeDefaultProviderId: agent.defaultProvider ?? null,
|
|
316
318
|
codeDefaultModelId: agent.defaultModel ?? null,
|
|
317
319
|
override: agentOverrideRow
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const authMock = jest.fn()
|
|
2
|
+
const loadAclMock = jest.fn()
|
|
3
|
+
const createRequestContainerMock = jest.fn()
|
|
4
|
+
const listDailyRollupMock = 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
|
+
jest.mock('../../../../data/repositories/AiTokenUsageRepository', () => ({
|
|
15
|
+
AiTokenUsageRepository: jest.fn().mockImplementation(() => ({
|
|
16
|
+
listDailyRollup: listDailyRollupMock,
|
|
17
|
+
})),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { GET } from '../route'
|
|
21
|
+
|
|
22
|
+
function buildRequest(params: Record<string, string> = {}) {
|
|
23
|
+
const url = new URL('http://localhost/api/ai_assistant/usage/daily')
|
|
24
|
+
for (const [k, v] of Object.entries(params)) {
|
|
25
|
+
url.searchParams.set(k, v)
|
|
26
|
+
}
|
|
27
|
+
return new Request(url.toString(), { method: 'GET' })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
31
|
+
return {
|
|
32
|
+
id: 'row-1',
|
|
33
|
+
tenantId: 'tenant-1',
|
|
34
|
+
organizationId: null,
|
|
35
|
+
day: '2026-05-01',
|
|
36
|
+
agentId: 'catalog.assistant',
|
|
37
|
+
modelId: 'claude-haiku-4-5',
|
|
38
|
+
providerId: 'anthropic',
|
|
39
|
+
inputTokens: '1000',
|
|
40
|
+
outputTokens: '500',
|
|
41
|
+
cachedInputTokens: '0',
|
|
42
|
+
reasoningTokens: '0',
|
|
43
|
+
stepCount: '5',
|
|
44
|
+
turnCount: '3',
|
|
45
|
+
sessionCount: '2',
|
|
46
|
+
createdAt: new Date('2026-05-01T12:00:00Z'),
|
|
47
|
+
updatedAt: new Date('2026-05-01T12:00:00Z'),
|
|
48
|
+
...overrides,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('GET /api/ai_assistant/usage/daily', () => {
|
|
53
|
+
let consoleErrorSpy: jest.SpyInstance
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
jest.clearAllMocks()
|
|
57
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
58
|
+
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: null })
|
|
59
|
+
loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
|
|
60
|
+
createRequestContainerMock.mockResolvedValue({
|
|
61
|
+
resolve: (name: string) => {
|
|
62
|
+
if (name === 'rbacService') return { loadAcl: loadAclMock, hasAllFeatures: (req: string[], have: string[]) => req.every((r) => have.includes(r)) }
|
|
63
|
+
if (name === 'em') return {}
|
|
64
|
+
throw new Error(`Unknown token: ${name}`)
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
listDailyRollupMock.mockResolvedValue([])
|
|
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({ from: '2026-05-01', to: '2026-05-31' }) as Parameters<typeof GET>[0])
|
|
77
|
+
expect(res.status).toBe(401)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns 403 when the caller lacks ai_assistant.settings.manage', async () => {
|
|
81
|
+
loadAclMock.mockResolvedValue({ features: ['ai_assistant.view'], isSuperAdmin: false })
|
|
82
|
+
const res = await GET(buildRequest({ from: '2026-05-01', to: '2026-05-31' }) 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('returns 400 when from has an invalid date format', async () => {
|
|
94
|
+
const res = await GET(buildRequest({ from: 'not-a-date', to: '2026-05-31' }) as Parameters<typeof GET>[0])
|
|
95
|
+
expect(res.status).toBe(400)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns 200 with empty rows when no data', async () => {
|
|
99
|
+
const res = await GET(buildRequest({ from: '2026-05-01', to: '2026-05-31' }) as Parameters<typeof GET>[0])
|
|
100
|
+
expect(res.status).toBe(200)
|
|
101
|
+
const body = await res.json()
|
|
102
|
+
expect(body.rows).toEqual([])
|
|
103
|
+
expect(body.total).toBe(0)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns 200 with serialized rows', async () => {
|
|
107
|
+
listDailyRollupMock.mockResolvedValue([makeRow()])
|
|
108
|
+
const res = await GET(buildRequest({ from: '2026-05-01', to: '2026-05-31' }) as Parameters<typeof GET>[0])
|
|
109
|
+
expect(res.status).toBe(200)
|
|
110
|
+
const body = await res.json()
|
|
111
|
+
expect(body.rows).toHaveLength(1)
|
|
112
|
+
expect(body.rows[0].agentId).toBe('catalog.assistant')
|
|
113
|
+
expect(body.total).toBe(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('serializes bigint counters and string timestamps returned by the database driver', async () => {
|
|
117
|
+
listDailyRollupMock.mockResolvedValue([
|
|
118
|
+
makeRow({
|
|
119
|
+
inputTokens: 1000n,
|
|
120
|
+
outputTokens: 500n,
|
|
121
|
+
cachedInputTokens: 10n,
|
|
122
|
+
reasoningTokens: 20n,
|
|
123
|
+
stepCount: 5n,
|
|
124
|
+
turnCount: 3n,
|
|
125
|
+
sessionCount: 2n,
|
|
126
|
+
createdAt: '2026-05-01T12:00:00.000Z',
|
|
127
|
+
updatedAt: '2026-05-01T12:30:00.000Z',
|
|
128
|
+
}),
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
const res = await GET(buildRequest({ from: '2026-05-01', to: '2026-05-31' }) as Parameters<typeof GET>[0])
|
|
132
|
+
|
|
133
|
+
expect(res.status).toBe(200)
|
|
134
|
+
const body = await res.json()
|
|
135
|
+
expect(body.rows[0]).toMatchObject({
|
|
136
|
+
inputTokens: '1000',
|
|
137
|
+
outputTokens: '500',
|
|
138
|
+
cachedInputTokens: '10',
|
|
139
|
+
reasoningTokens: '20',
|
|
140
|
+
stepCount: '5',
|
|
141
|
+
turnCount: '3',
|
|
142
|
+
sessionCount: '2',
|
|
143
|
+
createdAt: '2026-05-01T12:00:00.000Z',
|
|
144
|
+
updatedAt: '2026-05-01T12:30:00.000Z',
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('passes agentId filter to the repository', async () => {
|
|
149
|
+
const res = await GET(buildRequest({ from: '2026-05-01', to: '2026-05-31', agentId: 'catalog.assistant' }) as Parameters<typeof GET>[0])
|
|
150
|
+
expect(res.status).toBe(200)
|
|
151
|
+
expect(listDailyRollupMock).toHaveBeenCalledWith('tenant-1', '2026-05-01', '2026-05-31', { agentId: 'catalog.assistant', modelId: undefined })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('allows superadmin to bypass feature check', async () => {
|
|
155
|
+
loadAclMock.mockResolvedValue({ features: [], isSuperAdmin: true })
|
|
156
|
+
const res = await GET(buildRequest({ from: '2026-05-01', to: '2026-05-31' }) as Parameters<typeof GET>[0])
|
|
157
|
+
expect(res.status).toBe(200)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { toDateString, toIntegerString, toIsoString } from '../../../lib/usage-serialization'
|
|
11
|
+
|
|
12
|
+
const REQUIRED_FEATURE = 'ai_assistant.settings.manage'
|
|
13
|
+
|
|
14
|
+
const querySchema = z.object({
|
|
15
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be a date in YYYY-MM-DD format'),
|
|
16
|
+
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be a date in YYYY-MM-DD format'),
|
|
17
|
+
agentId: z.string().min(1).max(256).optional(),
|
|
18
|
+
modelId: z.string().min(1).max(256).optional(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const openApi: OpenApiRouteDoc = {
|
|
22
|
+
tag: 'AI Assistant',
|
|
23
|
+
summary: 'Token usage daily rollup',
|
|
24
|
+
methods: {
|
|
25
|
+
GET: {
|
|
26
|
+
operationId: 'aiAssistantUsageDaily',
|
|
27
|
+
summary: 'Fetch daily token-usage rollup rows for a date window.',
|
|
28
|
+
description:
|
|
29
|
+
'Returns aggregated token-usage data from `ai_token_usage_daily` for the given ' +
|
|
30
|
+
'date window. Tenant-scoped. Optionally filtered by `agentId` and/or `modelId`. ' +
|
|
31
|
+
'Requires `ai_assistant.settings.manage`.',
|
|
32
|
+
query: querySchema,
|
|
33
|
+
responses: [
|
|
34
|
+
{
|
|
35
|
+
status: 200,
|
|
36
|
+
description: 'Array of daily rollup rows.',
|
|
37
|
+
mediaType: 'application/json',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
errors: [
|
|
41
|
+
{ status: 400, description: 'Invalid query parameters.' },
|
|
42
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
43
|
+
{ status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
|
|
44
|
+
{ status: 500, description: 'Internal failure.' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const metadata = {
|
|
51
|
+
path: '/ai_assistant/usage/daily',
|
|
52
|
+
GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function jsonError(status: number, message: string, code: string, extra?: Record<string, unknown>): NextResponse {
|
|
56
|
+
return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function GET(req: NextRequest): Promise<Response> {
|
|
60
|
+
const auth = await getAuthFromRequest(req)
|
|
61
|
+
if (!auth) {
|
|
62
|
+
return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { searchParams } = new URL(req.url)
|
|
66
|
+
const rawQuery = {
|
|
67
|
+
from: searchParams.get('from') ?? undefined,
|
|
68
|
+
to: searchParams.get('to') ?? undefined,
|
|
69
|
+
agentId: searchParams.get('agentId') ?? undefined,
|
|
70
|
+
modelId: searchParams.get('modelId') ?? undefined,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const queryResult = querySchema.safeParse(rawQuery)
|
|
74
|
+
if (!queryResult.success) {
|
|
75
|
+
return jsonError(400, 'Invalid query parameters.', 'validation_error', {
|
|
76
|
+
issues: queryResult.error.issues,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { from, to, agentId, modelId } = queryResult.data
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const container = await createRequestContainer()
|
|
84
|
+
const rbacService = container.resolve<RbacService>('rbacService')
|
|
85
|
+
const acl = await rbacService.loadAcl(auth.sub, {
|
|
86
|
+
tenantId: auth.tenantId,
|
|
87
|
+
organizationId: auth.orgId,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
|
|
91
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!auth.tenantId) {
|
|
95
|
+
return NextResponse.json({ rows: [], total: 0 })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const em = container.resolve<EntityManager>('em')
|
|
99
|
+
const repo = new AiTokenUsageRepository(em)
|
|
100
|
+
const rows = await repo.listDailyRollup(auth.tenantId, from, to, { agentId, modelId })
|
|
101
|
+
|
|
102
|
+
const serialized = rows.map((row) => ({
|
|
103
|
+
id: row.id,
|
|
104
|
+
tenantId: row.tenantId,
|
|
105
|
+
organizationId: row.organizationId ?? null,
|
|
106
|
+
day: toDateString(row.day),
|
|
107
|
+
agentId: row.agentId,
|
|
108
|
+
modelId: row.modelId,
|
|
109
|
+
providerId: row.providerId,
|
|
110
|
+
inputTokens: toIntegerString(row.inputTokens),
|
|
111
|
+
outputTokens: toIntegerString(row.outputTokens),
|
|
112
|
+
cachedInputTokens: toIntegerString(row.cachedInputTokens),
|
|
113
|
+
reasoningTokens: toIntegerString(row.reasoningTokens),
|
|
114
|
+
stepCount: toIntegerString(row.stepCount),
|
|
115
|
+
turnCount: toIntegerString(row.turnCount),
|
|
116
|
+
sessionCount: toIntegerString(row.sessionCount),
|
|
117
|
+
createdAt: toIsoString(row.createdAt),
|
|
118
|
+
updatedAt: toIsoString(row.updatedAt),
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
return NextResponse.json({ rows: serialized, total: serialized.length })
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('[AI Usage Daily] GET error:', error)
|
|
124
|
+
return jsonError(500, 'Failed to fetch daily usage data.', 'internal_error')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const authMock = jest.fn()
|
|
2
|
+
const loadAclMock = jest.fn()
|
|
3
|
+
const createRequestContainerMock = jest.fn()
|
|
4
|
+
const listEventsForSessionMock = 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
|
+
jest.mock('../../../../../data/repositories/AiTokenUsageRepository', () => ({
|
|
15
|
+
AiTokenUsageRepository: jest.fn().mockImplementation(() => ({
|
|
16
|
+
listEventsForSession: listEventsForSessionMock,
|
|
17
|
+
})),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { GET } from '../route'
|
|
21
|
+
|
|
22
|
+
const SESSION_ID = '11111111-1111-4111-8111-111111111111'
|
|
23
|
+
|
|
24
|
+
function buildRequest() {
|
|
25
|
+
return new Request(`http://localhost/api/ai_assistant/usage/sessions/${SESSION_ID}`, { method: 'GET' })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildContext(sessionId: string) {
|
|
29
|
+
return { params: Promise.resolve({ sessionId }) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeEvent(overrides: Record<string, unknown> = {}) {
|
|
33
|
+
return {
|
|
34
|
+
id: 'evt-1',
|
|
35
|
+
tenantId: 'tenant-1',
|
|
36
|
+
organizationId: null,
|
|
37
|
+
userId: 'user-1',
|
|
38
|
+
agentId: 'catalog.assistant',
|
|
39
|
+
moduleId: 'catalog',
|
|
40
|
+
sessionId: SESSION_ID,
|
|
41
|
+
turnId: '22222222-2222-4222-8222-222222222222',
|
|
42
|
+
stepIndex: 0,
|
|
43
|
+
providerId: 'anthropic',
|
|
44
|
+
modelId: 'claude-haiku-4-5',
|
|
45
|
+
inputTokens: 100,
|
|
46
|
+
outputTokens: 50,
|
|
47
|
+
cachedInputTokens: null,
|
|
48
|
+
reasoningTokens: null,
|
|
49
|
+
finishReason: 'stop',
|
|
50
|
+
loopAbortReason: null,
|
|
51
|
+
createdAt: new Date('2026-05-01T12:00:00Z'),
|
|
52
|
+
updatedAt: new Date('2026-05-01T12:00:00Z'),
|
|
53
|
+
...overrides,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('GET /api/ai_assistant/usage/sessions/[sessionId]', () => {
|
|
58
|
+
let consoleErrorSpy: jest.SpyInstance
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
jest.clearAllMocks()
|
|
62
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
63
|
+
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: null })
|
|
64
|
+
loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
|
|
65
|
+
createRequestContainerMock.mockResolvedValue({
|
|
66
|
+
resolve: (name: string) => {
|
|
67
|
+
if (name === 'rbacService') return { loadAcl: loadAclMock, hasAllFeatures: (req: string[], have: string[]) => req.every((r) => have.includes(r)) }
|
|
68
|
+
if (name === 'em') return {}
|
|
69
|
+
throw new Error(`Unknown token: ${name}`)
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
listEventsForSessionMock.mockResolvedValue([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
consoleErrorSpy.mockRestore()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns 401 when unauthenticated', async () => {
|
|
80
|
+
authMock.mockResolvedValue(null)
|
|
81
|
+
const res = await GET(buildRequest() as Parameters<typeof GET>[0], buildContext(SESSION_ID))
|
|
82
|
+
expect(res.status).toBe(401)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns 403 when caller lacks ai_assistant.settings.manage', async () => {
|
|
86
|
+
loadAclMock.mockResolvedValue({ features: ['ai_assistant.view'], isSuperAdmin: false })
|
|
87
|
+
const res = await GET(buildRequest() as Parameters<typeof GET>[0], buildContext(SESSION_ID))
|
|
88
|
+
expect(res.status).toBe(403)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('returns 400 for an invalid (non-UUID) session id', async () => {
|
|
92
|
+
const res = await GET(buildRequest() as Parameters<typeof GET>[0], buildContext('not-a-uuid'))
|
|
93
|
+
expect(res.status).toBe(400)
|
|
94
|
+
const body = await res.json()
|
|
95
|
+
expect(body.code).toBe('validation_error')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns 404 when no events exist for the session', async () => {
|
|
99
|
+
listEventsForSessionMock.mockResolvedValue([])
|
|
100
|
+
const res = await GET(buildRequest() as Parameters<typeof GET>[0], buildContext(SESSION_ID))
|
|
101
|
+
expect(res.status).toBe(404)
|
|
102
|
+
const body = await res.json()
|
|
103
|
+
expect(body.code).toBe('session_not_found')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns 200 with serialized events when events exist', async () => {
|
|
107
|
+
listEventsForSessionMock.mockResolvedValue([
|
|
108
|
+
makeEvent({
|
|
109
|
+
stepIndex: 0n,
|
|
110
|
+
inputTokens: 100n,
|
|
111
|
+
outputTokens: 50n,
|
|
112
|
+
cachedInputTokens: 5n,
|
|
113
|
+
reasoningTokens: 7n,
|
|
114
|
+
createdAt: '2026-05-01T12:00:00.000Z',
|
|
115
|
+
updatedAt: '2026-05-01T12:30:00.000Z',
|
|
116
|
+
}),
|
|
117
|
+
])
|
|
118
|
+
const res = await GET(buildRequest() as Parameters<typeof GET>[0], buildContext(SESSION_ID))
|
|
119
|
+
expect(res.status).toBe(200)
|
|
120
|
+
const body = await res.json()
|
|
121
|
+
expect(body.events).toHaveLength(1)
|
|
122
|
+
expect(body.events[0].agentId).toBe('catalog.assistant')
|
|
123
|
+
expect(body.events[0].finishReason).toBe('stop')
|
|
124
|
+
expect(body.events[0]).toMatchObject({
|
|
125
|
+
stepIndex: 0,
|
|
126
|
+
inputTokens: 100,
|
|
127
|
+
outputTokens: 50,
|
|
128
|
+
cachedInputTokens: 5,
|
|
129
|
+
reasoningTokens: 7,
|
|
130
|
+
createdAt: '2026-05-01T12:00:00.000Z',
|
|
131
|
+
updatedAt: '2026-05-01T12:30:00.000Z',
|
|
132
|
+
})
|
|
133
|
+
expect(body.total).toBe(1)
|
|
134
|
+
expect(body.sessionId).toBe(SESSION_ID)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('allows superadmin to bypass feature check', async () => {
|
|
138
|
+
listEventsForSessionMock.mockResolvedValue([makeEvent()])
|
|
139
|
+
loadAclMock.mockResolvedValue({ features: [], isSuperAdmin: true })
|
|
140
|
+
const res = await GET(buildRequest() as Parameters<typeof GET>[0], buildContext(SESSION_ID))
|
|
141
|
+
expect(res.status).toBe(200)
|
|
142
|
+
})
|
|
143
|
+
})
|