@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
@@ -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 { runAiAgentText } from '../../../lib/agent-runtime'
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
- * Optional stable conversation id forwarded from `<AiChat>`. Bridged into
56
- * the Step 5.6 `prepareMutation` idempotency hash so repeated turns within
57
- * the same chat collapse onto the same pending action. Additive; omitted
58
- * bodies continue to work as before.
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 `allowRuntimeModelOverride: false`.
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 `allowRuntimeModelOverride: false`.',
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 allowRuntimeModelOverride:false), ' +
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
- if (hasRuntimeOverride) {
247
- if (agentDef.allowRuntimeModelOverride === false) {
248
- return jsonError(
249
- 400,
250
- `Agent "${agentId}" has runtime model override disabled (allowRuntimeModelOverride: false).`,
251
- 'runtime_override_disabled',
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
- allowRuntimeModelOverride: agent.allowRuntimeModelOverride,
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
- allowRuntimeModelOverride: agent.allowRuntimeModelOverride !== false,
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
+ })