1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use uuid::Uuid;
9
10use mnemo_core::error::Error as CoreError;
11use mnemo_core::hash::compute_content_hash;
12use mnemo_core::model::acl::Permission;
13use mnemo_core::model::delegation::{Delegation, DelegationScope};
14use mnemo_core::model::event::{AgentEvent, EventType};
15use mnemo_core::model::memory::{MemoryType, Scope};
16use mnemo_core::query::MnemoEngine;
17use mnemo_core::query::branch::{BranchRequest, BranchResponse};
18use mnemo_core::query::checkpoint::{CheckpointRequest, CheckpointResponse};
19use mnemo_core::query::consolidate::{ConsolidateRequest, ConsolidateResponse};
20use mnemo_core::query::forget::{
21 ForgetRequest, ForgetResponse, ForgetStrategy, ForgetSubjectRequest, ForgetSubjectResponse,
22};
23use mnemo_core::query::merge::{MergeRequest, MergeResponse};
24use mnemo_core::query::recall::{RecallRequest, RecallResponse};
25use mnemo_core::query::remember::{RememberRequest, RememberResponse};
26use mnemo_core::query::replay::{ReplayRequest, ReplayResponse};
27use mnemo_core::query::share::{ShareRequest, ShareResponse};
28
29type AppState = Arc<MnemoEngine>;
30
31pub struct AppError(CoreError);
36
37impl IntoResponse for AppError {
38 fn into_response(self) -> Response {
39 let (status, msg) = match &self.0 {
40 CoreError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()),
41 CoreError::PermissionDenied(m) => (StatusCode::FORBIDDEN, m.clone()),
42 CoreError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
43 other => {
44 tracing::error!("internal error: {other}");
45 (
46 StatusCode::INTERNAL_SERVER_ERROR,
47 "internal server error".to_string(),
48 )
49 }
50 };
51 (status, Json(serde_json::json!({"error": msg}))).into_response()
52 }
53}
54
55impl From<CoreError> for AppError {
56 fn from(e: CoreError) -> Self {
57 AppError(e)
58 }
59}
60
61#[derive(Debug, Deserialize)]
66pub struct RecallParams {
67 pub query: String,
68 pub agent_id: Option<String>,
69 pub limit: Option<usize>,
70 pub memory_type: Option<String>,
71 pub scope: Option<String>,
72 pub min_importance: Option<f32>,
73 pub tags: Option<String>,
74 pub org_id: Option<String>,
75 pub strategy: Option<String>,
76 pub as_of: Option<String>,
77 pub memory_types: Option<String>,
78 pub hybrid_weights: Option<String>,
79 pub rrf_k: Option<f32>,
80 pub explain: Option<bool>,
81 pub current_fact_key: Option<String>,
85 pub current_fact_include_chain: Option<bool>,
88 pub orientation_cache: Option<bool>,
95 pub orientation_namespace: Option<String>,
98 pub orientation_token_budget: Option<u32>,
101 pub orientation_include_in_response: Option<bool>,
104 pub orientation_distill: Option<bool>,
108}
109
110#[derive(Debug, Deserialize)]
111pub struct ForgetParams {
112 pub strategy: Option<String>,
113 pub agent_id: Option<String>,
114}
115
116#[derive(Debug, Deserialize)]
117pub struct ShareBody {
118 pub target_agent_id: String,
119 pub target_agent_ids: Option<Vec<String>>,
120 pub permission: Option<String>,
121 pub expires_in_hours: Option<f64>,
122 pub agent_id: Option<String>,
123}
124
125#[derive(Debug, Deserialize)]
126pub struct VerifyBody {
127 pub agent_id: Option<String>,
128 pub thread_id: Option<String>,
129}
130
131#[derive(Debug, Deserialize)]
132pub struct TrajectoryAuditBody {
133 pub agent_id: Option<String>,
134 pub thread_id: Option<String>,
135 pub active_bank_ceiling: Option<usize>,
136 pub fact_key: Option<String>,
137 pub named_forget_strategies: Option<Vec<String>>,
138}
139
140#[derive(Debug, Deserialize)]
141pub struct DelegateRequest {
142 pub delegate_id: String,
143 pub permission: String,
144 pub memory_ids: Option<Vec<String>>,
145 pub tags: Option<Vec<String>>,
146 pub max_depth: Option<u32>,
147 pub expires_in_hours: Option<f64>,
148 pub agent_id: Option<String>,
151}
152
153pub async fn remember_handler(
159 State(engine): State<AppState>,
160 Json(request): Json<RememberRequest>,
161) -> Result<Json<RememberResponse>, AppError> {
162 let response = engine.remember(request).await?;
163 Ok(Json(response))
164}
165
166pub async fn recall_handler(
168 State(engine): State<AppState>,
169 Query(params): Query<RecallParams>,
170) -> Result<Json<RecallResponse>, AppError> {
171 let memory_type = match params.memory_type.as_deref() {
172 Some(s) => Some(s.parse::<MemoryType>().map_err(|_| {
173 AppError(CoreError::Validation(format!(
174 "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
175 s
176 )))
177 })?),
178 None => None,
179 };
180
181 let scope = match params.scope.as_deref() {
182 Some(s) => Some(s.parse::<Scope>().map_err(|_| {
183 AppError(CoreError::Validation(format!(
184 "invalid scope '{}': expected one of: private, shared, public, global",
185 s
186 )))
187 })?),
188 None => None,
189 };
190
191 let tags = params.tags.as_deref().map(|t| {
192 t.split(',')
193 .map(|s| s.trim().to_string())
194 .collect::<Vec<_>>()
195 });
196
197 let memory_types = match params.memory_types.as_deref() {
198 Some(s) => {
199 let mut parsed = Vec::new();
200 for t in s.split(',') {
201 let trimmed = t.trim();
202 let mt = trimmed.parse::<MemoryType>().map_err(|_| {
203 AppError(CoreError::Validation(format!(
204 "invalid memory_type '{}' in memory_types: expected one of: episodic, semantic, procedural, working",
205 trimmed
206 )))
207 })?;
208 parsed.push(mt);
209 }
210 Some(parsed)
211 }
212 None => None,
213 };
214
215 let hybrid_weights = match params.hybrid_weights.as_deref() {
216 Some(s) => {
217 let mut weights = Vec::new();
218 for w in s.split(',') {
219 let trimmed = w.trim();
220 let val = trimmed.parse::<f32>().map_err(|_| {
221 AppError(CoreError::Validation(format!(
222 "invalid weight '{}' in hybrid_weights: expected a floating-point number",
223 trimmed
224 )))
225 })?;
226 weights.push(val);
227 }
228 Some(weights)
229 }
230 None => None,
231 };
232
233 let request = RecallRequest {
234 query: params.query,
235 agent_id: params.agent_id,
236 limit: params.limit,
237 memory_type,
238 memory_types,
239 scope,
240 min_importance: params.min_importance,
241 tags,
242 org_id: params.org_id,
243 strategy: params.strategy,
244 temporal_range: None,
245 recency_half_life_hours: None,
246 hybrid_weights,
247 rrf_k: params.rrf_k,
248 as_of: params.as_of,
249 explain: params.explain,
250 with_provenance: None,
251 mode: None,
252 current_fact_resolver: params.current_fact_key.map(|fact_key| {
253 mnemo_core::query::current_fact_resolver::CurrentFactResolverConfig {
254 fact_key,
255 include_supersession_chain: params.current_fact_include_chain.unwrap_or(false),
256 }
257 }),
258 orientation_cache: if params.orientation_cache.unwrap_or(false) {
259 Some(
260 mnemo_core::query::orientation_cache::OrientationCacheConfig {
261 namespace: params.orientation_namespace.clone(),
262 token_budget: params.orientation_token_budget,
263 include_in_response: params.orientation_include_in_response.unwrap_or(true),
264 distill: params.orientation_distill.unwrap_or(true),
265 },
266 )
267 } else {
268 None
269 },
270 evidence_budget: None,
271 retained_token_budget: None,
272 domain_scope: None,
273 reasoning_trust: None,
274 };
275
276 let response = engine.recall(request).await?;
277 Ok(Json(response))
278}
279
280pub async fn get_memory_handler(
282 State(engine): State<AppState>,
283 Path(id): Path<Uuid>,
284) -> Result<Json<serde_json::Value>, AppError> {
285 let record = engine
286 .storage
287 .get_memory(id)
288 .await?
289 .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
290
291 let value = serde_json::json!({
292 "id": record.id,
293 "agent_id": record.agent_id,
294 "content": record.content,
295 "memory_type": record.memory_type,
296 "scope": record.scope,
297 "importance": record.importance,
298 "tags": record.tags,
299 "metadata": record.metadata,
300 "source_type": record.source_type,
301 "source_id": record.source_id,
302 "consolidation_state": record.consolidation_state,
303 "access_count": record.access_count,
304 "org_id": record.org_id,
305 "thread_id": record.thread_id,
306 "created_at": record.created_at,
307 "updated_at": record.updated_at,
308 "last_accessed_at": record.last_accessed_at,
309 "expires_at": record.expires_at,
310 "deleted_at": record.deleted_at,
311 "decay_rate": record.decay_rate,
312 "created_by": record.created_by,
313 "version": record.version,
314 "prev_version_id": record.prev_version_id,
315 "quarantined": record.quarantined,
316 "quarantine_reason": record.quarantine_reason,
317 });
318
319 Ok(Json(value))
320}
321
322pub async fn forget_handler(
324 State(engine): State<AppState>,
325 Path(id): Path<Uuid>,
326 Query(params): Query<ForgetParams>,
327) -> Result<Json<ForgetResponse>, AppError> {
328 let strategy = match params.strategy.as_deref() {
329 Some(s) => Some(match s {
330 "soft_delete" => ForgetStrategy::SoftDelete,
331 "hard_delete" => ForgetStrategy::HardDelete,
332 "decay" => ForgetStrategy::Decay,
333 "consolidate" => ForgetStrategy::Consolidate,
334 "archive" => ForgetStrategy::Archive,
335 "redact" => ForgetStrategy::Redact,
336 other => {
337 return Err(AppError(CoreError::Validation(format!(
338 "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
339 other
340 ))));
341 }
342 }),
343 None => None,
344 };
345
346 let request = ForgetRequest {
347 memory_ids: vec![id],
348 agent_id: params.agent_id,
349 strategy,
350 criteria: None,
351 };
352
353 let response = engine.forget(request).await?;
354 Ok(Json(response))
355}
356
357#[derive(Debug, Deserialize)]
358pub struct ForgetSubjectBody {
359 pub subject_id: String,
360 pub strategy: Option<String>,
361 pub agent_id: Option<String>,
362}
363
364pub async fn forget_subject_handler(
366 State(engine): State<AppState>,
367 Json(body): Json<ForgetSubjectBody>,
368) -> Result<Json<ForgetSubjectResponse>, AppError> {
369 let strategy = match body.strategy.as_deref().unwrap_or("redact") {
370 "redact" => ForgetStrategy::Redact,
371 "hard_delete" => ForgetStrategy::HardDelete,
372 "soft_delete" => ForgetStrategy::SoftDelete,
373 other => {
374 return Err(AppError(CoreError::Validation(format!(
375 "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
376 other
377 ))));
378 }
379 };
380
381 let request = ForgetSubjectRequest {
382 subject_id: body.subject_id,
383 agent_id: body.agent_id,
384 strategy,
385 };
386
387 let response = engine.forget_subject(request).await?;
388 Ok(Json(response))
389}
390
391pub async fn share_handler(
393 State(engine): State<AppState>,
394 Path(id): Path<Uuid>,
395 Json(body): Json<ShareBody>,
396) -> Result<Json<ShareResponse>, AppError> {
397 let permission = match body.permission.as_deref() {
398 Some(s) => Some(s.parse::<Permission>().map_err(|_| {
399 AppError(CoreError::Validation(format!(
400 "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
401 s
402 )))
403 })?),
404 None => None,
405 };
406
407 let request = ShareRequest {
408 memory_id: id,
409 agent_id: body.agent_id,
410 target_agent_id: body.target_agent_id,
411 target_agent_ids: body.target_agent_ids,
412 permission,
413 expires_in_hours: body.expires_in_hours,
414 };
415
416 let response = engine.share(request).await?;
417 Ok(Json(response))
418}
419
420pub async fn checkpoint_handler(
422 State(engine): State<AppState>,
423 Json(request): Json<CheckpointRequest>,
424) -> Result<Json<CheckpointResponse>, AppError> {
425 let response = engine.checkpoint(request).await?;
426 Ok(Json(response))
427}
428
429pub async fn consolidate_handler(
431 State(engine): State<AppState>,
432 Json(request): Json<ConsolidateRequest>,
433) -> Result<Json<ConsolidateResponse>, AppError> {
434 let response = engine.consolidate(request).await?;
435 Ok(Json(response))
436}
437
438pub async fn branch_handler(
440 State(engine): State<AppState>,
441 Json(request): Json<BranchRequest>,
442) -> Result<Json<BranchResponse>, AppError> {
443 let response = engine.branch(request).await?;
444 Ok(Json(response))
445}
446
447pub async fn merge_handler(
449 State(engine): State<AppState>,
450 Json(request): Json<MergeRequest>,
451) -> Result<Json<MergeResponse>, AppError> {
452 let response = engine.merge(request).await?;
453 Ok(Json(response))
454}
455
456pub async fn replay_handler(
458 State(engine): State<AppState>,
459 Json(request): Json<ReplayRequest>,
460) -> Result<Json<ReplayResponse>, AppError> {
461 let response = engine.replay(request).await?;
462 Ok(Json(response))
463}
464
465pub async fn verify_handler(
467 State(engine): State<AppState>,
468 Json(body): Json<VerifyBody>,
469) -> Result<Json<serde_json::Value>, AppError> {
470 let result = engine
471 .verify_integrity(body.agent_id, body.thread_id.as_deref())
472 .await?;
473
474 let response = serde_json::json!({
475 "valid": result.valid,
476 "total_records": result.total_records,
477 "verified_records": result.verified_records,
478 "first_broken_at": result.first_broken_at.map(|id| id.to_string()),
479 "error_message": result.error_message,
480 "status": if result.valid { "verified" } else { "integrity_violation" },
481 });
482
483 Ok(Json(response))
484}
485
486pub async fn trajectory_audit_handler(
491 State(engine): State<AppState>,
492 Json(body): Json<TrajectoryAuditBody>,
493) -> Result<Json<serde_json::Value>, AppError> {
494 let agent_id = body
495 .agent_id
496 .clone()
497 .unwrap_or_else(|| engine.default_agent_id.clone());
498
499 let mut events = engine
502 .storage
503 .list_events(&agent_id, mnemo_core::query::MAX_BATCH_QUERY_LIMIT, 0)
504 .await?;
505 events.reverse();
506
507 let mut req = mnemo_compliance::trajectory::TrajectoryAuditRequest {
508 agent_id: Some(agent_id),
509 thread_id: body.thread_id.clone(),
510 ..Default::default()
511 };
512 if let Some(c) = body.active_bank_ceiling {
513 req.active_bank_ceiling = c;
514 }
515 if let Some(k) = body.fact_key {
516 req.fact_key = k;
517 }
518 if let Some(s) = body.named_forget_strategies {
519 req.named_forget_strategies = s;
520 }
521
522 let report = mnemo_compliance::trajectory::trajectory_audit(&events, &req)
523 .map_err(|e| AppError(CoreError::Validation(e.to_string())))?;
524
525 let response = serde_json::json!({
526 "report": report,
527 "all_ok": report.all_ok(),
528 });
529
530 Ok(Json(response))
531}
532
533pub async fn delegate_handler(
539 State(engine): State<AppState>,
540 Json(body): Json<DelegateRequest>,
541) -> Result<Json<serde_json::Value>, AppError> {
542 let permission: Permission = body
543 .permission
544 .parse()
545 .map_err(|e: CoreError| AppError(e))?;
546
547 let caller_agent_id = body
548 .agent_id
549 .unwrap_or_else(|| engine.default_agent_id.clone());
550
551 let scope = if let Some(ref ids) = body.memory_ids {
552 let parsed: std::result::Result<Vec<Uuid>, _> =
553 ids.iter().map(|s| Uuid::parse_str(s)).collect();
554 match parsed {
555 Ok(uuids) => {
556 for mid in &uuids {
558 let has_perm = engine
559 .storage
560 .check_permission(*mid, &caller_agent_id, Permission::Delegate)
561 .await?;
562 if !has_perm {
563 return Err(AppError(CoreError::PermissionDenied(format!(
564 "agent '{}' lacks delegate permission on memory {}",
565 caller_agent_id, mid
566 ))));
567 }
568 }
569 DelegationScope::ByMemoryId(uuids)
570 }
571 Err(e) => {
572 return Err(AppError(CoreError::Validation(format!(
573 "invalid UUID in memory_ids: {e}"
574 ))));
575 }
576 }
577 } else if let Some(ref tags) = body.tags {
578 DelegationScope::ByTag(tags.clone())
579 } else {
580 DelegationScope::AllMemories
581 };
582
583 let now = chrono::Utc::now();
584 let expires_at = body
585 .expires_in_hours
586 .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
587
588 let delegation = Delegation {
589 id: Uuid::now_v7(),
590 delegator_id: caller_agent_id,
591 delegate_id: body.delegate_id.clone(),
592 permission,
593 scope,
594 max_depth: body.max_depth.unwrap_or(0),
595 current_depth: 0,
596 parent_delegation_id: None,
597 created_at: now.to_rfc3339(),
598 expires_at,
599 revoked_at: None,
600 };
601
602 engine.storage.insert_delegation(&delegation).await?;
603
604 let response = serde_json::json!({
605 "delegation_id": delegation.id.to_string(),
606 "delegator": delegation.delegator_id,
607 "delegate": delegation.delegate_id,
608 "permission": delegation.permission.to_string(),
609 "status": "delegated",
610 });
611
612 Ok(Json(response))
613}
614
615pub async fn health_handler() -> Json<serde_json::Value> {
617 Json(serde_json::json!({"status": "ok"}))
618}
619
620struct GenAiFields {
625 event_type: EventType,
626 model: Option<String>,
627 tokens_input: Option<i64>,
628 tokens_output: Option<i64>,
629 cost_usd: Option<f64>,
630}
631
632fn extract_genai_fields(span: &serde_json::Value) -> GenAiFields {
635 let attributes = span.get("attributes").and_then(|v| v.as_array());
636
637 let mut model = None;
638 let mut tokens_input = None;
639 let mut tokens_output = None;
640 let mut cost_usd = None;
641 let mut operation_name = None;
642
643 if let Some(attrs) = attributes {
644 for attr in attrs {
645 let key = match attr.get("key").and_then(|k| k.as_str()) {
646 Some(k) => k,
647 None => continue,
648 };
649 let value = attr.get("value");
650
651 match key {
652 "gen_ai.request.model" => {
653 model = value
654 .and_then(|v| v.get("stringValue"))
655 .and_then(|v| v.as_str())
656 .map(|s| s.to_string());
657 }
658 "gen_ai.usage.input_tokens" => {
659 tokens_input = value.and_then(|v| v.get("intValue")).and_then(|v| {
660 v.as_str()
661 .and_then(|s| s.parse::<i64>().ok())
662 .or_else(|| v.as_i64())
663 });
664 }
665 "gen_ai.usage.output_tokens" => {
666 tokens_output = value.and_then(|v| v.get("intValue")).and_then(|v| {
667 v.as_str()
668 .and_then(|s| s.parse::<i64>().ok())
669 .or_else(|| v.as_i64())
670 });
671 }
672 "gen_ai.usage.cost" => {
673 cost_usd = value
674 .and_then(|v| v.get("doubleValue"))
675 .and_then(|v| v.as_f64());
676 }
677 "gen_ai.operation.name" => {
678 operation_name = value
679 .and_then(|v| v.get("stringValue"))
680 .and_then(|v| v.as_str())
681 .map(|s| s.to_string());
682 }
683 _ => {}
684 }
685 }
686 }
687
688 let op = operation_name.or_else(|| {
690 span.get("name")
691 .and_then(|v| v.as_str())
692 .map(|s| s.to_string())
693 });
694
695 let event_type = match op.as_deref() {
697 Some(s) if s.contains("chat") => EventType::AssistantMessage,
698 Some(s) if s.contains("embed") => EventType::RetrievalQuery,
699 Some(s) if s.contains("tool") => EventType::ToolCall,
700 _ => EventType::ToolCall, };
702
703 GenAiFields {
704 event_type,
705 model,
706 tokens_input,
707 tokens_output,
708 cost_usd,
709 }
710}
711
712pub async fn otlp_ingest_handler(
714 State(engine): State<AppState>,
715 Json(body): Json<serde_json::Value>,
716) -> Result<Json<serde_json::Value>, AppError> {
717 let resource_spans = body
718 .get("resourceSpans")
719 .and_then(|v| v.as_array())
720 .cloned()
721 .unwrap_or_default();
722
723 let mut count: u64 = 0;
724
725 for rs in &resource_spans {
726 let resource_agent_id = rs
728 .get("resource")
729 .and_then(|r| r.get("attributes"))
730 .and_then(|attrs| attrs.as_array())
731 .and_then(|attrs| {
732 attrs.iter().find_map(|attr| {
733 let key = attr.get("key")?.as_str()?;
734 if key == "agent.id" || key == "service.name" {
735 attr.get("value")
736 .and_then(|v| v.get("stringValue"))
737 .and_then(|v| v.as_str())
738 .map(|s| s.to_string())
739 } else {
740 None
741 }
742 })
743 });
744
745 let scope_spans = rs
746 .get("scopeSpans")
747 .and_then(|v| v.as_array())
748 .cloned()
749 .unwrap_or_default();
750
751 for ss in &scope_spans {
752 let spans = ss
753 .get("spans")
754 .and_then(|v| v.as_array())
755 .cloned()
756 .unwrap_or_default();
757
758 for span in &spans {
759 let trace_id = span
760 .get("traceId")
761 .and_then(|v| v.as_str())
762 .map(|s| s.to_string());
763
764 let span_id = span
765 .get("spanId")
766 .and_then(|v| v.as_str())
767 .map(|s| s.to_string());
768
769 let agent_id = resource_agent_id
770 .clone()
771 .unwrap_or_else(|| engine.default_agent_id.clone());
772
773 let start_nano: u64 = span
776 .get("startTimeUnixNano")
777 .and_then(|v| {
778 v.as_str()
779 .and_then(|s| s.parse::<u64>().ok())
780 .or_else(|| v.as_u64())
781 })
782 .unwrap_or(0);
783
784 let end_nano: u64 = span
785 .get("endTimeUnixNano")
786 .and_then(|v| {
787 v.as_str()
788 .and_then(|s| s.parse::<u64>().ok())
789 .or_else(|| v.as_u64())
790 })
791 .unwrap_or(0);
792
793 let latency_ms = if end_nano > start_nano {
794 Some(((end_nano - start_nano) / 1_000_000) as i64)
795 } else {
796 None
797 };
798
799 let timestamp = if start_nano > 0 {
801 let secs = (start_nano / 1_000_000_000) as i64;
802 let nsecs = (start_nano % 1_000_000_000) as u32;
803 chrono::DateTime::from_timestamp(secs, nsecs)
804 .map(|dt| dt.to_rfc3339())
805 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
806 } else {
807 chrono::Utc::now().to_rfc3339()
808 };
809
810 let payload = span
812 .get("attributes")
813 .cloned()
814 .unwrap_or(serde_json::json!({}));
815
816 let genai = extract_genai_fields(span);
817
818 let content_hash =
819 compute_content_hash(&payload.to_string(), &agent_id, ×tamp);
820
821 let event = AgentEvent {
822 id: Uuid::now_v7(),
823 agent_id,
824 thread_id: None,
825 run_id: None,
826 parent_event_id: None,
827 event_type: genai.event_type,
828 payload,
829 trace_id,
830 span_id,
831 model: genai.model,
832 tokens_input: genai.tokens_input,
833 tokens_output: genai.tokens_output,
834 latency_ms,
835 cost_usd: genai.cost_usd,
836 timestamp,
837 logical_clock: 0,
838 content_hash,
839 prev_hash: None,
840 embedding: None,
841 };
842
843 engine.storage.insert_event(&event).await?;
844 count += 1;
845 }
846 }
847 }
848
849 Ok(Json(serde_json::json!({"accepted": count})))
850}
851
852#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
861 fn test_extract_genai_fields_chat_span() {
862 let span = serde_json::json!({
863 "name": "chat gpt-4",
864 "attributes": [
865 {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4"}},
866 {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "150"}},
867 {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "50"}},
868 {"key": "gen_ai.usage.cost", "value": {"doubleValue": 0.006}},
869 {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}
870 ]
871 });
872 let fields = extract_genai_fields(&span);
873 assert_eq!(fields.event_type, EventType::AssistantMessage);
874 assert_eq!(fields.model.as_deref(), Some("gpt-4"));
875 assert_eq!(fields.tokens_input, Some(150));
876 assert_eq!(fields.tokens_output, Some(50));
877 assert!((fields.cost_usd.unwrap() - 0.006).abs() < 1e-9);
878 }
879
880 #[test]
881 fn test_extract_genai_fields_non_genai_default() {
882 let span = serde_json::json!({
883 "name": "http.request",
884 "attributes": [
885 {"key": "http.method", "value": {"stringValue": "GET"}}
886 ]
887 });
888 let fields = extract_genai_fields(&span);
889 assert_eq!(fields.event_type, EventType::ToolCall);
890 assert!(fields.model.is_none());
891 assert!(fields.tokens_input.is_none());
892 }
893}