@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
|
@@ -264,6 +264,13 @@ export class AiAgentRuntimeOverride {
|
|
|
264
264
|
| 'allowedOverrideModelsByProvider'
|
|
265
265
|
| 'updatedByUserId'
|
|
266
266
|
| 'deletedAt'
|
|
267
|
+
| 'loopDisabled'
|
|
268
|
+
| 'loopMaxSteps'
|
|
269
|
+
| 'loopMaxToolCalls'
|
|
270
|
+
| 'loopMaxWallClockMs'
|
|
271
|
+
| 'loopMaxTokens'
|
|
272
|
+
| 'loopStopWhenJson'
|
|
273
|
+
| 'loopActiveToolsJson'
|
|
267
274
|
|
|
268
275
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
269
276
|
id!: string
|
|
@@ -303,6 +310,236 @@ export class AiAgentRuntimeOverride {
|
|
|
303
310
|
|
|
304
311
|
@Property({ name: 'deleted_at', type: Date, nullable: true })
|
|
305
312
|
deletedAt?: Date | null
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Kill switch — when `true`, runtime forces `stopWhen: stepCountIs(1)` and
|
|
316
|
+
* ignores all other loop config. Phase 3 of spec
|
|
317
|
+
* `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
318
|
+
*/
|
|
319
|
+
@Property({ name: 'loop_disabled', type: 'boolean', nullable: true })
|
|
320
|
+
loopDisabled?: boolean | null
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Override `loop.maxSteps`. Phase 3 of spec
|
|
324
|
+
* `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
325
|
+
*/
|
|
326
|
+
@Property({ name: 'loop_max_steps', type: 'int', nullable: true })
|
|
327
|
+
loopMaxSteps?: number | null
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Override `loop.budget.maxToolCalls`. Phase 3 of spec
|
|
331
|
+
* `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
332
|
+
*/
|
|
333
|
+
@Property({ name: 'loop_max_tool_calls', type: 'int', nullable: true })
|
|
334
|
+
loopMaxToolCalls?: number | null
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Override `loop.budget.maxWallClockMs`. Phase 3 of spec
|
|
338
|
+
* `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
339
|
+
*/
|
|
340
|
+
@Property({ name: 'loop_max_wall_clock_ms', type: 'int', nullable: true })
|
|
341
|
+
loopMaxWallClockMs?: number | null
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Override `loop.budget.maxTokens`. Phase 3 of spec
|
|
345
|
+
* `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
346
|
+
*/
|
|
347
|
+
@Property({ name: 'loop_max_tokens', type: 'int', nullable: true })
|
|
348
|
+
loopMaxTokens?: number | null
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Override `loop.stopWhen`. JSON-safe variants only (`stepCount`,
|
|
352
|
+
* `hasToolCall`); validator rejects `kind: 'custom'`. Phase 3 of spec
|
|
353
|
+
* `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
354
|
+
*/
|
|
355
|
+
@Property({ name: 'loop_stop_when_json', type: 'jsonb', nullable: true })
|
|
356
|
+
loopStopWhenJson?: unknown | null
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Override `loop.activeTools` (must be subset of `agent.allowedTools`).
|
|
360
|
+
* Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
361
|
+
*/
|
|
362
|
+
@Property({ name: 'loop_active_tools_json', type: 'jsonb', nullable: true })
|
|
363
|
+
loopActiveToolsJson?: unknown | null
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Append-only event log for token usage per step (chat) or per turn (object).
|
|
368
|
+
*
|
|
369
|
+
* One row is created by `recordTokenUsage` (Phase 6.3) for every completed
|
|
370
|
+
* AI SDK step. Indexed for the three read patterns: daily rollup, per-agent
|
|
371
|
+
* report, and session drill-down.
|
|
372
|
+
*
|
|
373
|
+
* Retention: rows older than `AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS` (default
|
|
374
|
+
* 90) are swept by the `ai-token-usage-prune` worker (Phase 6.4).
|
|
375
|
+
*
|
|
376
|
+
* Phase 6.0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
377
|
+
*/
|
|
378
|
+
@Entity({ tableName: 'ai_token_usage_events' })
|
|
379
|
+
@Index({
|
|
380
|
+
name: 'ai_token_usage_events_tenant_created_idx',
|
|
381
|
+
properties: ['tenantId', 'createdAt'],
|
|
382
|
+
})
|
|
383
|
+
@Index({
|
|
384
|
+
name: 'ai_token_usage_events_tenant_agent_created_idx',
|
|
385
|
+
properties: ['tenantId', 'agentId', 'createdAt'],
|
|
386
|
+
})
|
|
387
|
+
@Index({
|
|
388
|
+
name: 'ai_token_usage_events_tenant_model_created_idx',
|
|
389
|
+
properties: ['tenantId', 'modelId', 'createdAt'],
|
|
390
|
+
})
|
|
391
|
+
@Index({
|
|
392
|
+
name: 'ai_token_usage_events_tenant_session_turn_step_idx',
|
|
393
|
+
properties: ['tenantId', 'sessionId', 'turnId', 'stepIndex'],
|
|
394
|
+
})
|
|
395
|
+
export class AiTokenUsageEvent {
|
|
396
|
+
[OptionalProps]?:
|
|
397
|
+
| 'createdAt'
|
|
398
|
+
| 'updatedAt'
|
|
399
|
+
| 'organizationId'
|
|
400
|
+
| 'cachedInputTokens'
|
|
401
|
+
| 'reasoningTokens'
|
|
402
|
+
| 'finishReason'
|
|
403
|
+
| 'loopAbortReason'
|
|
404
|
+
|
|
405
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
406
|
+
id!: string
|
|
407
|
+
|
|
408
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
409
|
+
tenantId!: string
|
|
410
|
+
|
|
411
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
412
|
+
organizationId?: string | null
|
|
413
|
+
|
|
414
|
+
@Property({ name: 'user_id', type: 'uuid' })
|
|
415
|
+
userId!: string
|
|
416
|
+
|
|
417
|
+
@Property({ name: 'agent_id', type: 'text' })
|
|
418
|
+
agentId!: string
|
|
419
|
+
|
|
420
|
+
@Property({ name: 'module_id', type: 'text' })
|
|
421
|
+
moduleId!: string
|
|
422
|
+
|
|
423
|
+
@Property({ name: 'session_id', type: 'uuid' })
|
|
424
|
+
sessionId!: string
|
|
425
|
+
|
|
426
|
+
@Property({ name: 'turn_id', type: 'uuid' })
|
|
427
|
+
turnId!: string
|
|
428
|
+
|
|
429
|
+
@Property({ name: 'step_index', type: 'int' })
|
|
430
|
+
stepIndex!: number
|
|
431
|
+
|
|
432
|
+
@Property({ name: 'provider_id', type: 'text' })
|
|
433
|
+
providerId!: string
|
|
434
|
+
|
|
435
|
+
@Property({ name: 'model_id', type: 'text' })
|
|
436
|
+
modelId!: string
|
|
437
|
+
|
|
438
|
+
@Property({ name: 'input_tokens', type: 'int' })
|
|
439
|
+
inputTokens!: number
|
|
440
|
+
|
|
441
|
+
@Property({ name: 'output_tokens', type: 'int' })
|
|
442
|
+
outputTokens!: number
|
|
443
|
+
|
|
444
|
+
@Property({ name: 'cached_input_tokens', type: 'int', nullable: true })
|
|
445
|
+
cachedInputTokens?: number | null
|
|
446
|
+
|
|
447
|
+
@Property({ name: 'reasoning_tokens', type: 'int', nullable: true })
|
|
448
|
+
reasoningTokens?: number | null
|
|
449
|
+
|
|
450
|
+
@Property({ name: 'finish_reason', type: 'text', nullable: true })
|
|
451
|
+
finishReason?: string | null
|
|
452
|
+
|
|
453
|
+
@Property({ name: 'loop_abort_reason', type: 'text', nullable: true })
|
|
454
|
+
loopAbortReason?: string | null
|
|
455
|
+
|
|
456
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
457
|
+
createdAt: Date = new Date()
|
|
458
|
+
|
|
459
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
460
|
+
updatedAt: Date = new Date()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Materialized daily rollup of token usage per `(tenant, day, agent, model)`.
|
|
465
|
+
*
|
|
466
|
+
* Updated incrementally by UPSERT on every `recordTokenUsage` call so the
|
|
467
|
+
* rollup is always current even when the prune worker is behind. A daily
|
|
468
|
+
* reconciliation worker (Phase 6.4) recomputes `session_count` from the events
|
|
469
|
+
* table to correct any drift caused by event delivery delays or outages.
|
|
470
|
+
*
|
|
471
|
+
* `session_count` is maintained via a per-row LATERAL exists check at write
|
|
472
|
+
* time (first event in a `(tenant, day, agent, model, session)` window
|
|
473
|
+
* increments the counter). This counter may drift if events arrive out of
|
|
474
|
+
* order; the daily worker corrects it.
|
|
475
|
+
*
|
|
476
|
+
* Phase 6.1 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
477
|
+
*/
|
|
478
|
+
@Entity({ tableName: 'ai_token_usage_daily' })
|
|
479
|
+
@Index({
|
|
480
|
+
name: 'ai_token_usage_daily_tenant_day_agent_model_org_uq',
|
|
481
|
+
expression:
|
|
482
|
+
'create unique index "ai_token_usage_daily_tenant_day_agent_model_org_uq" on "ai_token_usage_daily" ("tenant_id", "day", "agent_id", "model_id", "organization_id") where "organization_id" is not null',
|
|
483
|
+
})
|
|
484
|
+
@Index({
|
|
485
|
+
name: 'ai_token_usage_daily_tenant_day_agent_model_null_org_uq',
|
|
486
|
+
expression:
|
|
487
|
+
'create unique index "ai_token_usage_daily_tenant_day_agent_model_null_org_uq" on "ai_token_usage_daily" ("tenant_id", "day", "agent_id", "model_id") where "organization_id" is null',
|
|
488
|
+
})
|
|
489
|
+
@Index({
|
|
490
|
+
name: 'ai_token_usage_daily_tenant_day_idx',
|
|
491
|
+
properties: ['tenantId', 'day'],
|
|
492
|
+
})
|
|
493
|
+
export class AiTokenUsageDaily {
|
|
494
|
+
[OptionalProps]?: 'createdAt' | 'updatedAt' | 'organizationId'
|
|
495
|
+
|
|
496
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
497
|
+
id!: string
|
|
498
|
+
|
|
499
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
500
|
+
tenantId!: string
|
|
501
|
+
|
|
502
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
503
|
+
organizationId?: string | null
|
|
504
|
+
|
|
505
|
+
@Property({ name: 'day', type: 'string', columnType: 'date' })
|
|
506
|
+
day!: string
|
|
507
|
+
|
|
508
|
+
@Property({ name: 'agent_id', type: 'text' })
|
|
509
|
+
agentId!: string
|
|
510
|
+
|
|
511
|
+
@Property({ name: 'model_id', type: 'text' })
|
|
512
|
+
modelId!: string
|
|
513
|
+
|
|
514
|
+
@Property({ name: 'provider_id', type: 'text' })
|
|
515
|
+
providerId!: string
|
|
516
|
+
|
|
517
|
+
@Property({ name: 'input_tokens', type: 'string', columnType: 'bigint' })
|
|
518
|
+
inputTokens!: string
|
|
519
|
+
|
|
520
|
+
@Property({ name: 'output_tokens', type: 'string', columnType: 'bigint' })
|
|
521
|
+
outputTokens!: string
|
|
522
|
+
|
|
523
|
+
@Property({ name: 'cached_input_tokens', type: 'string', columnType: 'bigint' })
|
|
524
|
+
cachedInputTokens!: string
|
|
525
|
+
|
|
526
|
+
@Property({ name: 'reasoning_tokens', type: 'string', columnType: 'bigint' })
|
|
527
|
+
reasoningTokens!: string
|
|
528
|
+
|
|
529
|
+
@Property({ name: 'step_count', type: 'string', columnType: 'bigint' })
|
|
530
|
+
stepCount!: string
|
|
531
|
+
|
|
532
|
+
@Property({ name: 'turn_count', type: 'string', columnType: 'bigint' })
|
|
533
|
+
turnCount!: string
|
|
534
|
+
|
|
535
|
+
@Property({ name: 'session_count', type: 'string', columnType: 'bigint' })
|
|
536
|
+
sessionCount!: string
|
|
537
|
+
|
|
538
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
539
|
+
createdAt: Date = new Date()
|
|
540
|
+
|
|
541
|
+
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
542
|
+
updatedAt: Date = new Date()
|
|
306
543
|
}
|
|
307
544
|
|
|
308
545
|
/**
|
|
@@ -2,6 +2,7 @@ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
|
2
2
|
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
3
3
|
import { canonicalProviderId } from '../../lib/model-allowlist'
|
|
4
4
|
import { AiAgentRuntimeOverride } from '../entities'
|
|
5
|
+
import type { AiAgentLoopStopCondition } from '../../lib/ai-agent-definition'
|
|
5
6
|
|
|
6
7
|
export interface AiAgentRuntimeOverrideContext {
|
|
7
8
|
tenantId: string
|
|
@@ -9,7 +10,24 @@ export interface AiAgentRuntimeOverrideContext {
|
|
|
9
10
|
userId?: string | null
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export interface
|
|
13
|
+
export interface AiAgentRuntimeOverrideLoopInput {
|
|
14
|
+
/** Kill switch — when true, runtime forces stepCountIs(1). */
|
|
15
|
+
loopDisabled?: boolean | null
|
|
16
|
+
/** Override loop.maxSteps. */
|
|
17
|
+
loopMaxSteps?: number | null
|
|
18
|
+
/** Override loop.budget.maxToolCalls. */
|
|
19
|
+
loopMaxToolCalls?: number | null
|
|
20
|
+
/** Override loop.budget.maxWallClockMs. */
|
|
21
|
+
loopMaxWallClockMs?: number | null
|
|
22
|
+
/** Override loop.budget.maxTokens. */
|
|
23
|
+
loopMaxTokens?: number | null
|
|
24
|
+
/** Override loop.stopWhen — JSON-safe variants only (stepCount, hasToolCall). */
|
|
25
|
+
loopStopWhenJson?: AiAgentLoopStopCondition[] | null
|
|
26
|
+
/** Override loop.activeTools — must be a subset of agent.allowedTools. */
|
|
27
|
+
loopActiveToolsJson?: string[] | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AiAgentRuntimeOverrideInput extends AiAgentRuntimeOverrideLoopInput {
|
|
13
31
|
/** null means tenant-wide default (no agent pinning). */
|
|
14
32
|
agentId?: string | null
|
|
15
33
|
providerId?: string | null
|
|
@@ -17,6 +35,12 @@ export interface AiAgentRuntimeOverrideInput {
|
|
|
17
35
|
baseURL?: string | null
|
|
18
36
|
allowedOverrideProviders?: string[] | null
|
|
19
37
|
allowedOverrideModelsByProvider?: Record<string, string[]>
|
|
38
|
+
/**
|
|
39
|
+
* Optional: the agent's declared allowedTools. When provided, loopActiveToolsJson
|
|
40
|
+
* is validated to be a subset. When omitted, allowlist validation is skipped
|
|
41
|
+
* (write-time defense only; the runtime re-validates at read time).
|
|
42
|
+
*/
|
|
43
|
+
agentAllowedTools?: string[]
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
/**
|
|
@@ -90,6 +114,89 @@ export class AiAgentRuntimeOverrideRepository {
|
|
|
90
114
|
return row ?? null
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Validates and normalizes the loop override fields from an input object.
|
|
119
|
+
* Throws `AiAgentRuntimeOverrideValidationError` with code
|
|
120
|
+
* `invalid_loop_override` for any validation failure.
|
|
121
|
+
*
|
|
122
|
+
* Validation rules (Phase 3 — R5 mitigation):
|
|
123
|
+
* - `loopStopWhenJson`: all items must have kind `stepCount` or `hasToolCall`.
|
|
124
|
+
* Items with kind `custom` are rejected — they cannot be stored as JSON.
|
|
125
|
+
* - `loopActiveToolsJson`: when `agentAllowedTools` is provided, every entry
|
|
126
|
+
* must be in that allowlist.
|
|
127
|
+
*/
|
|
128
|
+
private validateLoopInput(input: AiAgentRuntimeOverrideInput): void {
|
|
129
|
+
if (input.loopStopWhenJson != null) {
|
|
130
|
+
if (!Array.isArray(input.loopStopWhenJson)) {
|
|
131
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
132
|
+
'loopStopWhenJson must be an array of stop condition objects.',
|
|
133
|
+
'invalid_loop_override',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
for (const item of input.loopStopWhenJson) {
|
|
137
|
+
if (!item || typeof item !== 'object' || !('kind' in item)) {
|
|
138
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
139
|
+
'loopStopWhenJson items must have a "kind" field.',
|
|
140
|
+
'invalid_loop_override',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
const kind = (item as AiAgentLoopStopCondition).kind
|
|
144
|
+
if (kind === 'custom') {
|
|
145
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
146
|
+
'loopStopWhenJson does not support kind "custom" — only "stepCount" and "hasToolCall" are JSON-safe and storable.',
|
|
147
|
+
'invalid_loop_override',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
if (kind !== 'stepCount' && kind !== 'hasToolCall') {
|
|
151
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
152
|
+
`loopStopWhenJson contains unknown kind "${String(kind)}". Allowed: "stepCount", "hasToolCall".`,
|
|
153
|
+
'invalid_loop_override',
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
if (kind === 'stepCount' && typeof (item as { count?: unknown }).count !== 'number') {
|
|
157
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
158
|
+
'loopStopWhenJson stepCount item must have a numeric "count" field.',
|
|
159
|
+
'invalid_loop_override',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if (kind === 'hasToolCall' && typeof (item as { toolName?: unknown }).toolName !== 'string') {
|
|
163
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
164
|
+
'loopStopWhenJson hasToolCall item must have a string "toolName" field.',
|
|
165
|
+
'invalid_loop_override',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (input.loopActiveToolsJson != null) {
|
|
172
|
+
if (!Array.isArray(input.loopActiveToolsJson)) {
|
|
173
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
174
|
+
'loopActiveToolsJson must be an array of tool name strings.',
|
|
175
|
+
'invalid_loop_override',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
for (const name of input.loopActiveToolsJson) {
|
|
179
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
180
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
181
|
+
'loopActiveToolsJson entries must be non-empty strings.',
|
|
182
|
+
'invalid_loop_override',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (input.agentAllowedTools && input.agentAllowedTools.length > 0) {
|
|
187
|
+
const outsideAllowlist = input.loopActiveToolsJson.filter(
|
|
188
|
+
(name) => !input.agentAllowedTools!.includes(name),
|
|
189
|
+
)
|
|
190
|
+
if (outsideAllowlist.length > 0) {
|
|
191
|
+
throw new AiAgentRuntimeOverrideValidationError(
|
|
192
|
+
`loopActiveToolsJson contains tools outside the agent's allowedTools: ${outsideAllowlist.join(', ')}.`,
|
|
193
|
+
'invalid_loop_override',
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
93
200
|
/**
|
|
94
201
|
* Inserts or updates the runtime override for the given context.
|
|
95
202
|
*
|
|
@@ -97,6 +204,11 @@ export class AiAgentRuntimeOverrideRepository {
|
|
|
97
204
|
* cannot save a typo (Phase 1.4 contract re-applied per spec §Data Models).
|
|
98
205
|
* An unknown provider id throws a typed error.
|
|
99
206
|
*
|
|
207
|
+
* Also validates loop override fields (R5 mitigation — Phase 3):
|
|
208
|
+
* - `loopStopWhenJson` items must use only JSON-safe kinds.
|
|
209
|
+
* - `loopActiveToolsJson` items must be a subset of `agentAllowedTools`
|
|
210
|
+
* when that is provided.
|
|
211
|
+
*
|
|
100
212
|
* The R6 base-URL allowlist check is intentionally NOT performed here —
|
|
101
213
|
* that enforcement lives at the HTTP layer (PUT settings route). The
|
|
102
214
|
* repository trusts that callers have already validated the value.
|
|
@@ -121,6 +233,8 @@ export class AiAgentRuntimeOverrideRepository {
|
|
|
121
233
|
}
|
|
122
234
|
}
|
|
123
235
|
|
|
236
|
+
this.validateLoopInput(input)
|
|
237
|
+
|
|
124
238
|
const orgFilter = ctx.organizationId ?? null
|
|
125
239
|
const agentIdFilter = input.agentId ?? null
|
|
126
240
|
const hasProviderId = Object.prototype.hasOwnProperty.call(input, 'providerId')
|
|
@@ -149,6 +263,13 @@ export class AiAgentRuntimeOverrideRepository {
|
|
|
149
263
|
}
|
|
150
264
|
existing.updatedByUserId = ctx.userId ?? null
|
|
151
265
|
existing.updatedAt = new Date()
|
|
266
|
+
if ('loopDisabled' in input) existing.loopDisabled = input.loopDisabled ?? null
|
|
267
|
+
if ('loopMaxSteps' in input) existing.loopMaxSteps = input.loopMaxSteps ?? null
|
|
268
|
+
if ('loopMaxToolCalls' in input) existing.loopMaxToolCalls = input.loopMaxToolCalls ?? null
|
|
269
|
+
if ('loopMaxWallClockMs' in input) existing.loopMaxWallClockMs = input.loopMaxWallClockMs ?? null
|
|
270
|
+
if ('loopMaxTokens' in input) existing.loopMaxTokens = input.loopMaxTokens ?? null
|
|
271
|
+
if ('loopStopWhenJson' in input) existing.loopStopWhenJson = input.loopStopWhenJson ?? null
|
|
272
|
+
if ('loopActiveToolsJson' in input) existing.loopActiveToolsJson = input.loopActiveToolsJson ?? null
|
|
152
273
|
await tx.persist(existing).flush()
|
|
153
274
|
return existing
|
|
154
275
|
}
|
|
@@ -167,6 +288,13 @@ export class AiAgentRuntimeOverrideRepository {
|
|
|
167
288
|
? (input.allowedOverrideModelsByProvider ?? {})
|
|
168
289
|
: {},
|
|
169
290
|
updatedByUserId: ctx.userId ?? null,
|
|
291
|
+
loopDisabled: input.loopDisabled ?? null,
|
|
292
|
+
loopMaxSteps: input.loopMaxSteps ?? null,
|
|
293
|
+
loopMaxToolCalls: input.loopMaxToolCalls ?? null,
|
|
294
|
+
loopMaxWallClockMs: input.loopMaxWallClockMs ?? null,
|
|
295
|
+
loopMaxTokens: input.loopMaxTokens ?? null,
|
|
296
|
+
loopStopWhenJson: input.loopStopWhenJson ?? null,
|
|
297
|
+
loopActiveToolsJson: input.loopActiveToolsJson ?? null,
|
|
170
298
|
} as unknown as AiAgentRuntimeOverride)
|
|
171
299
|
await tx.persist(row).flush()
|
|
172
300
|
return row
|
|
@@ -215,12 +343,16 @@ export class AiAgentRuntimeOverrideRepository {
|
|
|
215
343
|
}
|
|
216
344
|
|
|
217
345
|
/**
|
|
218
|
-
* Thrown by `upsertDefault` when
|
|
346
|
+
* Thrown by `upsertDefault` when validation fails (unknown provider id,
|
|
347
|
+
* invalid loop override JSON).
|
|
219
348
|
*/
|
|
220
349
|
export class AiAgentRuntimeOverrideValidationError extends Error {
|
|
221
|
-
|
|
350
|
+
readonly code: string
|
|
351
|
+
|
|
352
|
+
constructor(message: string, code = 'invalid_override') {
|
|
222
353
|
super(message)
|
|
223
354
|
this.name = 'AiAgentRuntimeOverrideValidationError'
|
|
355
|
+
this.code = code
|
|
224
356
|
}
|
|
225
357
|
}
|
|
226
358
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { AiTokenUsageEvent, AiTokenUsageDaily } from '../entities'
|
|
3
|
+
|
|
4
|
+
export interface CreateTokenUsageEventInput {
|
|
5
|
+
tenantId: string
|
|
6
|
+
organizationId?: string | null
|
|
7
|
+
userId: string
|
|
8
|
+
agentId: string
|
|
9
|
+
moduleId: string
|
|
10
|
+
sessionId: string
|
|
11
|
+
turnId: string
|
|
12
|
+
stepIndex: number
|
|
13
|
+
providerId: string
|
|
14
|
+
modelId: string
|
|
15
|
+
inputTokens: number
|
|
16
|
+
outputTokens: number
|
|
17
|
+
cachedInputTokens?: number | null
|
|
18
|
+
reasoningTokens?: number | null
|
|
19
|
+
finishReason?: string | null
|
|
20
|
+
loopAbortReason?: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UpsertTokenUsageDailyInput {
|
|
24
|
+
tenantId: string
|
|
25
|
+
organizationId?: string | null
|
|
26
|
+
day: string
|
|
27
|
+
agentId: string
|
|
28
|
+
modelId: string
|
|
29
|
+
providerId: string
|
|
30
|
+
sessionId: string
|
|
31
|
+
inputTokens: number
|
|
32
|
+
outputTokens: number
|
|
33
|
+
cachedInputTokens: number
|
|
34
|
+
reasoningTokens: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Repository for the Phase 6 token-usage event log and daily rollup tables.
|
|
39
|
+
*
|
|
40
|
+
* `upsertDaily` uses raw SQL to perform the CONFLICT-based incremental update
|
|
41
|
+
* because MikroORM does not expose `INSERT ... ON CONFLICT DO UPDATE` for
|
|
42
|
+
* arbitrary expressions. The LATERAL session-count check guards against
|
|
43
|
+
* double-counting a session within the same `(tenant, day, agent, model)` tuple.
|
|
44
|
+
*
|
|
45
|
+
* All writes are fail-open — callers MUST wrap invocations in try/catch and
|
|
46
|
+
* log at `warn` rather than rethrowing (R12: recorder must never break a turn).
|
|
47
|
+
*
|
|
48
|
+
* Phase 6.1 + 6.3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
49
|
+
*/
|
|
50
|
+
export class AiTokenUsageRepository {
|
|
51
|
+
constructor(private readonly em: EntityManager) {}
|
|
52
|
+
|
|
53
|
+
async createEvent(input: CreateTokenUsageEventInput): Promise<AiTokenUsageEvent> {
|
|
54
|
+
const event = this.em.create(AiTokenUsageEvent, {
|
|
55
|
+
tenantId: input.tenantId,
|
|
56
|
+
organizationId: input.organizationId ?? null,
|
|
57
|
+
userId: input.userId,
|
|
58
|
+
agentId: input.agentId,
|
|
59
|
+
moduleId: input.moduleId,
|
|
60
|
+
sessionId: input.sessionId,
|
|
61
|
+
turnId: input.turnId,
|
|
62
|
+
stepIndex: input.stepIndex,
|
|
63
|
+
providerId: input.providerId,
|
|
64
|
+
modelId: input.modelId,
|
|
65
|
+
inputTokens: input.inputTokens,
|
|
66
|
+
outputTokens: input.outputTokens,
|
|
67
|
+
cachedInputTokens: input.cachedInputTokens ?? null,
|
|
68
|
+
reasoningTokens: input.reasoningTokens ?? null,
|
|
69
|
+
finishReason: input.finishReason ?? null,
|
|
70
|
+
loopAbortReason: input.loopAbortReason ?? null,
|
|
71
|
+
})
|
|
72
|
+
this.em.persist(event)
|
|
73
|
+
await this.em.flush()
|
|
74
|
+
return event
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Upserts the daily rollup row, incrementing counters atomically via
|
|
79
|
+
* `INSERT ... ON CONFLICT DO UPDATE`. The `session_count` column is
|
|
80
|
+
* incremented only when this is the first event observed for the
|
|
81
|
+
* `(tenant_id, session_id, day, agent_id, model_id)` tuple — a LATERAL
|
|
82
|
+
* NOT EXISTS check prevents double-counting.
|
|
83
|
+
*
|
|
84
|
+
* The query handles the two partial unique indexes (org IS NOT NULL vs
|
|
85
|
+
* IS NULL) by encoding `organization_id` in the EXCLUDED row and relying
|
|
86
|
+
* on the appropriate partial index the planner selects.
|
|
87
|
+
*/
|
|
88
|
+
async upsertDaily(input: UpsertTokenUsageDailyInput): Promise<void> {
|
|
89
|
+
const connection = this.em.getConnection()
|
|
90
|
+
const now = new Date()
|
|
91
|
+
const orgValue = input.organizationId ?? null
|
|
92
|
+
|
|
93
|
+
// Determine if this is the first event for this session in the window
|
|
94
|
+
// (used to guard the session_count increment).
|
|
95
|
+
const sessionCheckSql = `
|
|
96
|
+
select exists (
|
|
97
|
+
select 1 from ai_token_usage_events
|
|
98
|
+
where tenant_id = ?
|
|
99
|
+
and session_id = ?::uuid
|
|
100
|
+
and agent_id = ?
|
|
101
|
+
and model_id = ?
|
|
102
|
+
and date_trunc('day', created_at) = ?::date
|
|
103
|
+
${orgValue !== null ? 'and organization_id = ?' : 'and organization_id is null'}
|
|
104
|
+
) as already_seen
|
|
105
|
+
`
|
|
106
|
+
const sessionCheckParams: unknown[] = [
|
|
107
|
+
input.tenantId,
|
|
108
|
+
input.sessionId,
|
|
109
|
+
input.agentId,
|
|
110
|
+
input.modelId,
|
|
111
|
+
input.day,
|
|
112
|
+
]
|
|
113
|
+
if (orgValue !== null) sessionCheckParams.push(orgValue)
|
|
114
|
+
|
|
115
|
+
const sessionRows = await connection.execute(sessionCheckSql, sessionCheckParams, 'all')
|
|
116
|
+
const alreadySeen =
|
|
117
|
+
Array.isArray(sessionRows) &&
|
|
118
|
+
sessionRows.length > 0 &&
|
|
119
|
+
(sessionRows[0] as Record<string, unknown>).already_seen === true
|
|
120
|
+
|
|
121
|
+
const sessionDelta = alreadySeen ? 0 : 1
|
|
122
|
+
|
|
123
|
+
if (orgValue !== null) {
|
|
124
|
+
await connection.execute(
|
|
125
|
+
`
|
|
126
|
+
insert into ai_token_usage_daily (
|
|
127
|
+
id, tenant_id, organization_id, day, agent_id, model_id, provider_id,
|
|
128
|
+
input_tokens, output_tokens, cached_input_tokens, reasoning_tokens,
|
|
129
|
+
step_count, turn_count, session_count, created_at, updated_at
|
|
130
|
+
) values (
|
|
131
|
+
gen_random_uuid(), ?, ?, ?::date, ?, ?, ?,
|
|
132
|
+
?, ?, ?, ?,
|
|
133
|
+
1, 1, ?, ?, ?
|
|
134
|
+
)
|
|
135
|
+
on conflict (tenant_id, day, agent_id, model_id, organization_id)
|
|
136
|
+
where organization_id is not null
|
|
137
|
+
do update set
|
|
138
|
+
input_tokens = ai_token_usage_daily.input_tokens + excluded.input_tokens,
|
|
139
|
+
output_tokens = ai_token_usage_daily.output_tokens + excluded.output_tokens,
|
|
140
|
+
cached_input_tokens = ai_token_usage_daily.cached_input_tokens + excluded.cached_input_tokens,
|
|
141
|
+
reasoning_tokens = ai_token_usage_daily.reasoning_tokens + excluded.reasoning_tokens,
|
|
142
|
+
step_count = ai_token_usage_daily.step_count + 1,
|
|
143
|
+
turn_count = ai_token_usage_daily.turn_count + 1,
|
|
144
|
+
session_count = ai_token_usage_daily.session_count + excluded.session_count,
|
|
145
|
+
updated_at = excluded.updated_at
|
|
146
|
+
`,
|
|
147
|
+
[
|
|
148
|
+
input.tenantId, orgValue, input.day, input.agentId, input.modelId, input.providerId,
|
|
149
|
+
input.inputTokens, input.outputTokens, input.cachedInputTokens, input.reasoningTokens,
|
|
150
|
+
sessionDelta, now, now,
|
|
151
|
+
],
|
|
152
|
+
'run',
|
|
153
|
+
)
|
|
154
|
+
} else {
|
|
155
|
+
await connection.execute(
|
|
156
|
+
`
|
|
157
|
+
insert into ai_token_usage_daily (
|
|
158
|
+
id, tenant_id, organization_id, day, agent_id, model_id, provider_id,
|
|
159
|
+
input_tokens, output_tokens, cached_input_tokens, reasoning_tokens,
|
|
160
|
+
step_count, turn_count, session_count, created_at, updated_at
|
|
161
|
+
) values (
|
|
162
|
+
gen_random_uuid(), ?, null, ?::date, ?, ?, ?,
|
|
163
|
+
?, ?, ?, ?,
|
|
164
|
+
1, 1, ?, ?, ?
|
|
165
|
+
)
|
|
166
|
+
on conflict (tenant_id, day, agent_id, model_id)
|
|
167
|
+
where organization_id is null
|
|
168
|
+
do update set
|
|
169
|
+
input_tokens = ai_token_usage_daily.input_tokens + excluded.input_tokens,
|
|
170
|
+
output_tokens = ai_token_usage_daily.output_tokens + excluded.output_tokens,
|
|
171
|
+
cached_input_tokens = ai_token_usage_daily.cached_input_tokens + excluded.cached_input_tokens,
|
|
172
|
+
reasoning_tokens = ai_token_usage_daily.reasoning_tokens + excluded.reasoning_tokens,
|
|
173
|
+
step_count = ai_token_usage_daily.step_count + 1,
|
|
174
|
+
turn_count = ai_token_usage_daily.turn_count + 1,
|
|
175
|
+
session_count = ai_token_usage_daily.session_count + excluded.session_count,
|
|
176
|
+
updated_at = excluded.updated_at
|
|
177
|
+
`,
|
|
178
|
+
[
|
|
179
|
+
input.tenantId, input.day, input.agentId, input.modelId, input.providerId,
|
|
180
|
+
input.inputTokens, input.outputTokens, input.cachedInputTokens, input.reasoningTokens,
|
|
181
|
+
sessionDelta, now, now,
|
|
182
|
+
],
|
|
183
|
+
'run',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async listEventsForSession(
|
|
189
|
+
tenantId: string,
|
|
190
|
+
sessionId: string,
|
|
191
|
+
limit = 200,
|
|
192
|
+
): Promise<AiTokenUsageEvent[]> {
|
|
193
|
+
return this.em.find(
|
|
194
|
+
AiTokenUsageEvent,
|
|
195
|
+
{ tenantId, sessionId },
|
|
196
|
+
{ orderBy: { createdAt: 'ASC', stepIndex: 'ASC' }, limit },
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async listDailyRollup(
|
|
201
|
+
tenantId: string,
|
|
202
|
+
from: string,
|
|
203
|
+
to: string,
|
|
204
|
+
filters: { agentId?: string; modelId?: string } = {},
|
|
205
|
+
): Promise<AiTokenUsageDaily[]> {
|
|
206
|
+
const where: Record<string, unknown> = { tenantId, day: { $gte: from, $lte: to } }
|
|
207
|
+
if (filters.agentId) where.agentId = filters.agentId
|
|
208
|
+
if (filters.modelId) where.modelId = filters.modelId
|
|
209
|
+
return this.em.find(AiTokenUsageDaily, where, {
|
|
210
|
+
orderBy: { day: 'ASC', agentId: 'ASC', modelId: 'ASC' },
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|