Skip to main content

mnemo_rest/
handlers.rs

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::forget::{
20    ForgetRequest, ForgetResponse, ForgetStrategy, ForgetSubjectRequest, ForgetSubjectResponse,
21};
22use mnemo_core::query::merge::{MergeRequest, MergeResponse};
23use mnemo_core::query::recall::{RecallRequest, RecallResponse};
24use mnemo_core::query::remember::{RememberRequest, RememberResponse};
25use mnemo_core::query::replay::{ReplayRequest, ReplayResponse};
26use mnemo_core::query::share::{ShareRequest, ShareResponse};
27
28type AppState = Arc<MnemoEngine>;
29
30// ---------------------------------------------------------------------------
31// Error handling
32// ---------------------------------------------------------------------------
33
34pub struct AppError(CoreError);
35
36impl IntoResponse for AppError {
37    fn into_response(self) -> Response {
38        let (status, msg) = match &self.0 {
39            CoreError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()),
40            CoreError::PermissionDenied(m) => (StatusCode::FORBIDDEN, m.clone()),
41            CoreError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
42            other => {
43                tracing::error!("internal error: {other}");
44                (
45                    StatusCode::INTERNAL_SERVER_ERROR,
46                    "internal server error".to_string(),
47                )
48            }
49        };
50        (status, Json(serde_json::json!({"error": msg}))).into_response()
51    }
52}
53
54impl From<CoreError> for AppError {
55    fn from(e: CoreError) -> Self {
56        AppError(e)
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Query / body helper structs
62// ---------------------------------------------------------------------------
63
64#[derive(Debug, Deserialize)]
65pub struct RecallParams {
66    pub query: String,
67    pub agent_id: Option<String>,
68    pub limit: Option<usize>,
69    pub memory_type: Option<String>,
70    pub scope: Option<String>,
71    pub min_importance: Option<f32>,
72    pub tags: Option<String>,
73    pub org_id: Option<String>,
74    pub strategy: Option<String>,
75    pub as_of: Option<String>,
76    pub memory_types: Option<String>,
77    pub hybrid_weights: Option<String>,
78    pub rrf_k: Option<f32>,
79    pub explain: Option<bool>,
80}
81
82#[derive(Debug, Deserialize)]
83pub struct ForgetParams {
84    pub strategy: Option<String>,
85    pub agent_id: Option<String>,
86}
87
88#[derive(Debug, Deserialize)]
89pub struct ShareBody {
90    pub target_agent_id: String,
91    pub target_agent_ids: Option<Vec<String>>,
92    pub permission: Option<String>,
93    pub expires_in_hours: Option<f64>,
94    pub agent_id: Option<String>,
95}
96
97#[derive(Debug, Deserialize)]
98pub struct VerifyBody {
99    pub agent_id: Option<String>,
100    pub thread_id: Option<String>,
101}
102
103#[derive(Debug, Deserialize)]
104pub struct DelegateRequest {
105    pub delegate_id: String,
106    pub permission: String,
107    pub memory_ids: Option<Vec<String>>,
108    pub tags: Option<Vec<String>>,
109    pub max_depth: Option<u32>,
110    pub expires_in_hours: Option<f64>,
111    /// The agent requesting delegation. Required — the server will verify
112    /// this agent has `Delegate` permission on the target memories.
113    pub agent_id: Option<String>,
114}
115
116// ---------------------------------------------------------------------------
117// Handlers
118// ---------------------------------------------------------------------------
119
120/// POST /v1/memories -- store a new memory.
121pub async fn remember_handler(
122    State(engine): State<AppState>,
123    Json(request): Json<RememberRequest>,
124) -> Result<Json<RememberResponse>, AppError> {
125    let response = engine.remember(request).await?;
126    Ok(Json(response))
127}
128
129/// GET /v1/memories?query=...&limit=...&memory_type=...&scope=...&strategy=...
130pub async fn recall_handler(
131    State(engine): State<AppState>,
132    Query(params): Query<RecallParams>,
133) -> Result<Json<RecallResponse>, AppError> {
134    let memory_type = match params.memory_type.as_deref() {
135        Some(s) => Some(s.parse::<MemoryType>().map_err(|_| {
136            AppError(CoreError::Validation(format!(
137                "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
138                s
139            )))
140        })?),
141        None => None,
142    };
143
144    let scope = match params.scope.as_deref() {
145        Some(s) => Some(s.parse::<Scope>().map_err(|_| {
146            AppError(CoreError::Validation(format!(
147                "invalid scope '{}': expected one of: private, shared, public, global",
148                s
149            )))
150        })?),
151        None => None,
152    };
153
154    let tags = params.tags.as_deref().map(|t| {
155        t.split(',')
156            .map(|s| s.trim().to_string())
157            .collect::<Vec<_>>()
158    });
159
160    let memory_types = match params.memory_types.as_deref() {
161        Some(s) => {
162            let mut parsed = Vec::new();
163            for t in s.split(',') {
164                let trimmed = t.trim();
165                let mt = trimmed.parse::<MemoryType>().map_err(|_| {
166                    AppError(CoreError::Validation(format!(
167                        "invalid memory_type '{}' in memory_types: expected one of: episodic, semantic, procedural, working",
168                        trimmed
169                    )))
170                })?;
171                parsed.push(mt);
172            }
173            Some(parsed)
174        }
175        None => None,
176    };
177
178    let hybrid_weights = match params.hybrid_weights.as_deref() {
179        Some(s) => {
180            let mut weights = Vec::new();
181            for w in s.split(',') {
182                let trimmed = w.trim();
183                let val = trimmed.parse::<f32>().map_err(|_| {
184                    AppError(CoreError::Validation(format!(
185                        "invalid weight '{}' in hybrid_weights: expected a floating-point number",
186                        trimmed
187                    )))
188                })?;
189                weights.push(val);
190            }
191            Some(weights)
192        }
193        None => None,
194    };
195
196    let request = RecallRequest {
197        query: params.query,
198        agent_id: params.agent_id,
199        limit: params.limit,
200        memory_type,
201        memory_types,
202        scope,
203        min_importance: params.min_importance,
204        tags,
205        org_id: params.org_id,
206        strategy: params.strategy,
207        temporal_range: None,
208        recency_half_life_hours: None,
209        hybrid_weights,
210        rrf_k: params.rrf_k,
211        as_of: params.as_of,
212        explain: params.explain,
213        with_provenance: None,
214    };
215
216    let response = engine.recall(request).await?;
217    Ok(Json(response))
218}
219
220/// GET /v1/memories/:id -- retrieve a single memory by UUID.
221pub async fn get_memory_handler(
222    State(engine): State<AppState>,
223    Path(id): Path<Uuid>,
224) -> Result<Json<serde_json::Value>, AppError> {
225    let record = engine
226        .storage
227        .get_memory(id)
228        .await?
229        .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
230
231    let value = serde_json::json!({
232        "id": record.id,
233        "agent_id": record.agent_id,
234        "content": record.content,
235        "memory_type": record.memory_type,
236        "scope": record.scope,
237        "importance": record.importance,
238        "tags": record.tags,
239        "metadata": record.metadata,
240        "source_type": record.source_type,
241        "source_id": record.source_id,
242        "consolidation_state": record.consolidation_state,
243        "access_count": record.access_count,
244        "org_id": record.org_id,
245        "thread_id": record.thread_id,
246        "created_at": record.created_at,
247        "updated_at": record.updated_at,
248        "last_accessed_at": record.last_accessed_at,
249        "expires_at": record.expires_at,
250        "deleted_at": record.deleted_at,
251        "decay_rate": record.decay_rate,
252        "created_by": record.created_by,
253        "version": record.version,
254        "prev_version_id": record.prev_version_id,
255        "quarantined": record.quarantined,
256        "quarantine_reason": record.quarantine_reason,
257    });
258
259    Ok(Json(value))
260}
261
262/// DELETE /v1/memories/:id?strategy=soft_delete|hard_delete|decay|consolidate|archive
263pub async fn forget_handler(
264    State(engine): State<AppState>,
265    Path(id): Path<Uuid>,
266    Query(params): Query<ForgetParams>,
267) -> Result<Json<ForgetResponse>, AppError> {
268    let strategy = match params.strategy.as_deref() {
269        Some(s) => Some(match s {
270            "soft_delete" => ForgetStrategy::SoftDelete,
271            "hard_delete" => ForgetStrategy::HardDelete,
272            "decay" => ForgetStrategy::Decay,
273            "consolidate" => ForgetStrategy::Consolidate,
274            "archive" => ForgetStrategy::Archive,
275            "redact" => ForgetStrategy::Redact,
276            other => {
277                return Err(AppError(CoreError::Validation(format!(
278                    "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
279                    other
280                ))));
281            }
282        }),
283        None => None,
284    };
285
286    let request = ForgetRequest {
287        memory_ids: vec![id],
288        agent_id: params.agent_id,
289        strategy,
290        criteria: None,
291    };
292
293    let response = engine.forget(request).await?;
294    Ok(Json(response))
295}
296
297#[derive(Debug, Deserialize)]
298pub struct ForgetSubjectBody {
299    pub subject_id: String,
300    pub strategy: Option<String>,
301    pub agent_id: Option<String>,
302}
303
304/// POST /v1/forget_subject — GDPR / DPDPA-aligned subject erasure.
305pub async fn forget_subject_handler(
306    State(engine): State<AppState>,
307    Json(body): Json<ForgetSubjectBody>,
308) -> Result<Json<ForgetSubjectResponse>, AppError> {
309    let strategy = match body.strategy.as_deref().unwrap_or("redact") {
310        "redact" => ForgetStrategy::Redact,
311        "hard_delete" => ForgetStrategy::HardDelete,
312        "soft_delete" => ForgetStrategy::SoftDelete,
313        other => {
314            return Err(AppError(CoreError::Validation(format!(
315                "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
316                other
317            ))));
318        }
319    };
320
321    let request = ForgetSubjectRequest {
322        subject_id: body.subject_id,
323        agent_id: body.agent_id,
324        strategy,
325    };
326
327    let response = engine.forget_subject(request).await?;
328    Ok(Json(response))
329}
330
331/// POST /v1/memories/:id/share
332pub async fn share_handler(
333    State(engine): State<AppState>,
334    Path(id): Path<Uuid>,
335    Json(body): Json<ShareBody>,
336) -> Result<Json<ShareResponse>, AppError> {
337    let permission = match body.permission.as_deref() {
338        Some(s) => Some(s.parse::<Permission>().map_err(|_| {
339            AppError(CoreError::Validation(format!(
340                "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
341                s
342            )))
343        })?),
344        None => None,
345    };
346
347    let request = ShareRequest {
348        memory_id: id,
349        agent_id: body.agent_id,
350        target_agent_id: body.target_agent_id,
351        target_agent_ids: body.target_agent_ids,
352        permission,
353        expires_in_hours: body.expires_in_hours,
354    };
355
356    let response = engine.share(request).await?;
357    Ok(Json(response))
358}
359
360/// POST /v1/checkpoints
361pub async fn checkpoint_handler(
362    State(engine): State<AppState>,
363    Json(request): Json<CheckpointRequest>,
364) -> Result<Json<CheckpointResponse>, AppError> {
365    let response = engine.checkpoint(request).await?;
366    Ok(Json(response))
367}
368
369/// POST /v1/branches
370pub async fn branch_handler(
371    State(engine): State<AppState>,
372    Json(request): Json<BranchRequest>,
373) -> Result<Json<BranchResponse>, AppError> {
374    let response = engine.branch(request).await?;
375    Ok(Json(response))
376}
377
378/// POST /v1/merge
379pub async fn merge_handler(
380    State(engine): State<AppState>,
381    Json(request): Json<MergeRequest>,
382) -> Result<Json<MergeResponse>, AppError> {
383    let response = engine.merge(request).await?;
384    Ok(Json(response))
385}
386
387/// POST /v1/replay
388pub async fn replay_handler(
389    State(engine): State<AppState>,
390    Json(request): Json<ReplayRequest>,
391) -> Result<Json<ReplayResponse>, AppError> {
392    let response = engine.replay(request).await?;
393    Ok(Json(response))
394}
395
396/// POST /v1/verify -- verify hash chain integrity.
397pub async fn verify_handler(
398    State(engine): State<AppState>,
399    Json(body): Json<VerifyBody>,
400) -> Result<Json<serde_json::Value>, AppError> {
401    let result = engine
402        .verify_integrity(body.agent_id, body.thread_id.as_deref())
403        .await?;
404
405    let response = serde_json::json!({
406        "valid": result.valid,
407        "total_records": result.total_records,
408        "verified_records": result.verified_records,
409        "first_broken_at": result.first_broken_at.map(|id| id.to_string()),
410        "error_message": result.error_message,
411        "status": if result.valid { "verified" } else { "integrity_violation" },
412    });
413
414    Ok(Json(response))
415}
416
417/// POST /v1/delegate -- delegate permissions to another agent.
418///
419/// The caller must provide their `agent_id` and must have `Delegate`
420/// permission on the target memories. Without a full auth middleware
421/// this is advisory; production deployments should add an auth layer.
422pub async fn delegate_handler(
423    State(engine): State<AppState>,
424    Json(body): Json<DelegateRequest>,
425) -> Result<Json<serde_json::Value>, AppError> {
426    let permission: Permission = body
427        .permission
428        .parse()
429        .map_err(|e: CoreError| AppError(e))?;
430
431    let caller_agent_id = body
432        .agent_id
433        .unwrap_or_else(|| engine.default_agent_id.clone());
434
435    let scope = if let Some(ref ids) = body.memory_ids {
436        let parsed: std::result::Result<Vec<Uuid>, _> =
437            ids.iter().map(|s| Uuid::parse_str(s)).collect();
438        match parsed {
439            Ok(uuids) => {
440                // Verify caller has Delegate permission on each memory
441                for mid in &uuids {
442                    let has_perm = engine
443                        .storage
444                        .check_permission(*mid, &caller_agent_id, Permission::Delegate)
445                        .await?;
446                    if !has_perm {
447                        return Err(AppError(CoreError::PermissionDenied(format!(
448                            "agent '{}' lacks delegate permission on memory {}",
449                            caller_agent_id, mid
450                        ))));
451                    }
452                }
453                DelegationScope::ByMemoryId(uuids)
454            }
455            Err(e) => {
456                return Err(AppError(CoreError::Validation(format!(
457                    "invalid UUID in memory_ids: {e}"
458                ))));
459            }
460        }
461    } else if let Some(ref tags) = body.tags {
462        DelegationScope::ByTag(tags.clone())
463    } else {
464        DelegationScope::AllMemories
465    };
466
467    let now = chrono::Utc::now();
468    let expires_at = body
469        .expires_in_hours
470        .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
471
472    let delegation = Delegation {
473        id: Uuid::now_v7(),
474        delegator_id: caller_agent_id,
475        delegate_id: body.delegate_id.clone(),
476        permission,
477        scope,
478        max_depth: body.max_depth.unwrap_or(0),
479        current_depth: 0,
480        parent_delegation_id: None,
481        created_at: now.to_rfc3339(),
482        expires_at,
483        revoked_at: None,
484    };
485
486    engine.storage.insert_delegation(&delegation).await?;
487
488    let response = serde_json::json!({
489        "delegation_id": delegation.id.to_string(),
490        "delegator": delegation.delegator_id,
491        "delegate": delegation.delegate_id,
492        "permission": delegation.permission.to_string(),
493        "status": "delegated",
494    });
495
496    Ok(Json(response))
497}
498
499/// GET /v1/health
500pub async fn health_handler() -> Json<serde_json::Value> {
501    Json(serde_json::json!({"status": "ok"}))
502}
503
504// ---------------------------------------------------------------------------
505// GenAI semantic convention helpers
506// ---------------------------------------------------------------------------
507
508struct GenAiFields {
509    event_type: EventType,
510    model: Option<String>,
511    tokens_input: Option<i64>,
512    tokens_output: Option<i64>,
513    cost_usd: Option<f64>,
514}
515
516/// Extract GenAI semantic convention fields from OTLP span attributes.
517/// See: <https://opentelemetry.io/docs/specs/semconv/gen-ai/>
518fn extract_genai_fields(span: &serde_json::Value) -> GenAiFields {
519    let attributes = span.get("attributes").and_then(|v| v.as_array());
520
521    let mut model = None;
522    let mut tokens_input = None;
523    let mut tokens_output = None;
524    let mut cost_usd = None;
525    let mut operation_name = None;
526
527    if let Some(attrs) = attributes {
528        for attr in attrs {
529            let key = match attr.get("key").and_then(|k| k.as_str()) {
530                Some(k) => k,
531                None => continue,
532            };
533            let value = attr.get("value");
534
535            match key {
536                "gen_ai.request.model" => {
537                    model = value
538                        .and_then(|v| v.get("stringValue"))
539                        .and_then(|v| v.as_str())
540                        .map(|s| s.to_string());
541                }
542                "gen_ai.usage.input_tokens" => {
543                    tokens_input = value.and_then(|v| v.get("intValue")).and_then(|v| {
544                        v.as_str()
545                            .and_then(|s| s.parse::<i64>().ok())
546                            .or_else(|| v.as_i64())
547                    });
548                }
549                "gen_ai.usage.output_tokens" => {
550                    tokens_output = value.and_then(|v| v.get("intValue")).and_then(|v| {
551                        v.as_str()
552                            .and_then(|s| s.parse::<i64>().ok())
553                            .or_else(|| v.as_i64())
554                    });
555                }
556                "gen_ai.usage.cost" => {
557                    cost_usd = value
558                        .and_then(|v| v.get("doubleValue"))
559                        .and_then(|v| v.as_f64());
560                }
561                "gen_ai.operation.name" => {
562                    operation_name = value
563                        .and_then(|v| v.get("stringValue"))
564                        .and_then(|v| v.as_str())
565                        .map(|s| s.to_string());
566                }
567                _ => {}
568            }
569        }
570    }
571
572    // If no operation_name from attributes, fall back to span name.
573    let op = operation_name.or_else(|| {
574        span.get("name")
575            .and_then(|v| v.as_str())
576            .map(|s| s.to_string())
577    });
578
579    // Map operation name to EventType.
580    let event_type = match op.as_deref() {
581        Some(s) if s.contains("chat") => EventType::AssistantMessage,
582        Some(s) if s.contains("embed") => EventType::RetrievalQuery,
583        Some(s) if s.contains("tool") => EventType::ToolCall,
584        _ => EventType::ToolCall, // default
585    };
586
587    GenAiFields {
588        event_type,
589        model,
590        tokens_input,
591        tokens_output,
592        cost_usd,
593    }
594}
595
596/// POST /v1/ingest/otlp -- ingest simplified OTLP JSON spans as agent events.
597pub async fn otlp_ingest_handler(
598    State(engine): State<AppState>,
599    Json(body): Json<serde_json::Value>,
600) -> Result<Json<serde_json::Value>, AppError> {
601    let resource_spans = body
602        .get("resourceSpans")
603        .and_then(|v| v.as_array())
604        .cloned()
605        .unwrap_or_default();
606
607    let mut count: u64 = 0;
608
609    for rs in &resource_spans {
610        // Extract agent_id from resource attributes (service.name or agent.id).
611        let resource_agent_id = rs
612            .get("resource")
613            .and_then(|r| r.get("attributes"))
614            .and_then(|attrs| attrs.as_array())
615            .and_then(|attrs| {
616                attrs.iter().find_map(|attr| {
617                    let key = attr.get("key")?.as_str()?;
618                    if key == "agent.id" || key == "service.name" {
619                        attr.get("value")
620                            .and_then(|v| v.get("stringValue"))
621                            .and_then(|v| v.as_str())
622                            .map(|s| s.to_string())
623                    } else {
624                        None
625                    }
626                })
627            });
628
629        let scope_spans = rs
630            .get("scopeSpans")
631            .and_then(|v| v.as_array())
632            .cloned()
633            .unwrap_or_default();
634
635        for ss in &scope_spans {
636            let spans = ss
637                .get("spans")
638                .and_then(|v| v.as_array())
639                .cloned()
640                .unwrap_or_default();
641
642            for span in &spans {
643                let trace_id = span
644                    .get("traceId")
645                    .and_then(|v| v.as_str())
646                    .map(|s| s.to_string());
647
648                let span_id = span
649                    .get("spanId")
650                    .and_then(|v| v.as_str())
651                    .map(|s| s.to_string());
652
653                let agent_id = resource_agent_id
654                    .clone()
655                    .unwrap_or_else(|| engine.default_agent_id.clone());
656
657                // Compute latency from start/end nanosecond timestamps.
658                // OTLP encodes nanos as either JSON strings or integers.
659                let start_nano: u64 = span
660                    .get("startTimeUnixNano")
661                    .and_then(|v| {
662                        v.as_str()
663                            .and_then(|s| s.parse::<u64>().ok())
664                            .or_else(|| v.as_u64())
665                    })
666                    .unwrap_or(0);
667
668                let end_nano: u64 = span
669                    .get("endTimeUnixNano")
670                    .and_then(|v| {
671                        v.as_str()
672                            .and_then(|s| s.parse::<u64>().ok())
673                            .or_else(|| v.as_u64())
674                    })
675                    .unwrap_or(0);
676
677                let latency_ms = if end_nano > start_nano {
678                    Some(((end_nano - start_nano) / 1_000_000) as i64)
679                } else {
680                    None
681                };
682
683                // Convert startTimeUnixNano to RFC3339 timestamp.
684                let timestamp = if start_nano > 0 {
685                    let secs = (start_nano / 1_000_000_000) as i64;
686                    let nsecs = (start_nano % 1_000_000_000) as u32;
687                    chrono::DateTime::from_timestamp(secs, nsecs)
688                        .map(|dt| dt.to_rfc3339())
689                        .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
690                } else {
691                    chrono::Utc::now().to_rfc3339()
692                };
693
694                // Collect span attributes as the event payload.
695                let payload = span
696                    .get("attributes")
697                    .cloned()
698                    .unwrap_or(serde_json::json!({}));
699
700                let genai = extract_genai_fields(span);
701
702                let content_hash =
703                    compute_content_hash(&payload.to_string(), &agent_id, &timestamp);
704
705                let event = AgentEvent {
706                    id: Uuid::now_v7(),
707                    agent_id,
708                    thread_id: None,
709                    run_id: None,
710                    parent_event_id: None,
711                    event_type: genai.event_type,
712                    payload,
713                    trace_id,
714                    span_id,
715                    model: genai.model,
716                    tokens_input: genai.tokens_input,
717                    tokens_output: genai.tokens_output,
718                    latency_ms,
719                    cost_usd: genai.cost_usd,
720                    timestamp,
721                    logical_clock: 0,
722                    content_hash,
723                    prev_hash: None,
724                    embedding: None,
725                };
726
727                engine.storage.insert_event(&event).await?;
728                count += 1;
729            }
730        }
731    }
732
733    Ok(Json(serde_json::json!({"accepted": count})))
734}
735
736// ---------------------------------------------------------------------------
737// Tests
738// ---------------------------------------------------------------------------
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn test_extract_genai_fields_chat_span() {
746        let span = serde_json::json!({
747            "name": "chat gpt-4",
748            "attributes": [
749                {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4"}},
750                {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "150"}},
751                {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "50"}},
752                {"key": "gen_ai.usage.cost", "value": {"doubleValue": 0.006}},
753                {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}
754            ]
755        });
756        let fields = extract_genai_fields(&span);
757        assert_eq!(fields.event_type, EventType::AssistantMessage);
758        assert_eq!(fields.model.as_deref(), Some("gpt-4"));
759        assert_eq!(fields.tokens_input, Some(150));
760        assert_eq!(fields.tokens_output, Some(50));
761        assert!((fields.cost_usd.unwrap() - 0.006).abs() < 1e-9);
762    }
763
764    #[test]
765    fn test_extract_genai_fields_non_genai_default() {
766        let span = serde_json::json!({
767            "name": "http.request",
768            "attributes": [
769                {"key": "http.method", "value": {"stringValue": "GET"}}
770            ]
771        });
772        let fields = extract_genai_fields(&span);
773        assert_eq!(fields.event_type, EventType::ToolCall);
774        assert!(fields.model.is_none());
775        assert!(fields.tokens_input.is_none());
776    }
777}