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        mode: None,
215    };
216
217    let response = engine.recall(request).await?;
218    Ok(Json(response))
219}
220
221/// GET /v1/memories/:id -- retrieve a single memory by UUID.
222pub async fn get_memory_handler(
223    State(engine): State<AppState>,
224    Path(id): Path<Uuid>,
225) -> Result<Json<serde_json::Value>, AppError> {
226    let record = engine
227        .storage
228        .get_memory(id)
229        .await?
230        .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
231
232    let value = serde_json::json!({
233        "id": record.id,
234        "agent_id": record.agent_id,
235        "content": record.content,
236        "memory_type": record.memory_type,
237        "scope": record.scope,
238        "importance": record.importance,
239        "tags": record.tags,
240        "metadata": record.metadata,
241        "source_type": record.source_type,
242        "source_id": record.source_id,
243        "consolidation_state": record.consolidation_state,
244        "access_count": record.access_count,
245        "org_id": record.org_id,
246        "thread_id": record.thread_id,
247        "created_at": record.created_at,
248        "updated_at": record.updated_at,
249        "last_accessed_at": record.last_accessed_at,
250        "expires_at": record.expires_at,
251        "deleted_at": record.deleted_at,
252        "decay_rate": record.decay_rate,
253        "created_by": record.created_by,
254        "version": record.version,
255        "prev_version_id": record.prev_version_id,
256        "quarantined": record.quarantined,
257        "quarantine_reason": record.quarantine_reason,
258    });
259
260    Ok(Json(value))
261}
262
263/// DELETE /v1/memories/:id?strategy=soft_delete|hard_delete|decay|consolidate|archive
264pub async fn forget_handler(
265    State(engine): State<AppState>,
266    Path(id): Path<Uuid>,
267    Query(params): Query<ForgetParams>,
268) -> Result<Json<ForgetResponse>, AppError> {
269    let strategy = match params.strategy.as_deref() {
270        Some(s) => Some(match s {
271            "soft_delete" => ForgetStrategy::SoftDelete,
272            "hard_delete" => ForgetStrategy::HardDelete,
273            "decay" => ForgetStrategy::Decay,
274            "consolidate" => ForgetStrategy::Consolidate,
275            "archive" => ForgetStrategy::Archive,
276            "redact" => ForgetStrategy::Redact,
277            other => {
278                return Err(AppError(CoreError::Validation(format!(
279                    "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
280                    other
281                ))));
282            }
283        }),
284        None => None,
285    };
286
287    let request = ForgetRequest {
288        memory_ids: vec![id],
289        agent_id: params.agent_id,
290        strategy,
291        criteria: None,
292    };
293
294    let response = engine.forget(request).await?;
295    Ok(Json(response))
296}
297
298#[derive(Debug, Deserialize)]
299pub struct ForgetSubjectBody {
300    pub subject_id: String,
301    pub strategy: Option<String>,
302    pub agent_id: Option<String>,
303}
304
305/// POST /v1/forget_subject — GDPR / DPDPA-aligned subject erasure.
306pub async fn forget_subject_handler(
307    State(engine): State<AppState>,
308    Json(body): Json<ForgetSubjectBody>,
309) -> Result<Json<ForgetSubjectResponse>, AppError> {
310    let strategy = match body.strategy.as_deref().unwrap_or("redact") {
311        "redact" => ForgetStrategy::Redact,
312        "hard_delete" => ForgetStrategy::HardDelete,
313        "soft_delete" => ForgetStrategy::SoftDelete,
314        other => {
315            return Err(AppError(CoreError::Validation(format!(
316                "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
317                other
318            ))));
319        }
320    };
321
322    let request = ForgetSubjectRequest {
323        subject_id: body.subject_id,
324        agent_id: body.agent_id,
325        strategy,
326    };
327
328    let response = engine.forget_subject(request).await?;
329    Ok(Json(response))
330}
331
332/// POST /v1/memories/:id/share
333pub async fn share_handler(
334    State(engine): State<AppState>,
335    Path(id): Path<Uuid>,
336    Json(body): Json<ShareBody>,
337) -> Result<Json<ShareResponse>, AppError> {
338    let permission = match body.permission.as_deref() {
339        Some(s) => Some(s.parse::<Permission>().map_err(|_| {
340            AppError(CoreError::Validation(format!(
341                "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
342                s
343            )))
344        })?),
345        None => None,
346    };
347
348    let request = ShareRequest {
349        memory_id: id,
350        agent_id: body.agent_id,
351        target_agent_id: body.target_agent_id,
352        target_agent_ids: body.target_agent_ids,
353        permission,
354        expires_in_hours: body.expires_in_hours,
355    };
356
357    let response = engine.share(request).await?;
358    Ok(Json(response))
359}
360
361/// POST /v1/checkpoints
362pub async fn checkpoint_handler(
363    State(engine): State<AppState>,
364    Json(request): Json<CheckpointRequest>,
365) -> Result<Json<CheckpointResponse>, AppError> {
366    let response = engine.checkpoint(request).await?;
367    Ok(Json(response))
368}
369
370/// POST /v1/branches
371pub async fn branch_handler(
372    State(engine): State<AppState>,
373    Json(request): Json<BranchRequest>,
374) -> Result<Json<BranchResponse>, AppError> {
375    let response = engine.branch(request).await?;
376    Ok(Json(response))
377}
378
379/// POST /v1/merge
380pub async fn merge_handler(
381    State(engine): State<AppState>,
382    Json(request): Json<MergeRequest>,
383) -> Result<Json<MergeResponse>, AppError> {
384    let response = engine.merge(request).await?;
385    Ok(Json(response))
386}
387
388/// POST /v1/replay
389pub async fn replay_handler(
390    State(engine): State<AppState>,
391    Json(request): Json<ReplayRequest>,
392) -> Result<Json<ReplayResponse>, AppError> {
393    let response = engine.replay(request).await?;
394    Ok(Json(response))
395}
396
397/// POST /v1/verify -- verify hash chain integrity.
398pub async fn verify_handler(
399    State(engine): State<AppState>,
400    Json(body): Json<VerifyBody>,
401) -> Result<Json<serde_json::Value>, AppError> {
402    let result = engine
403        .verify_integrity(body.agent_id, body.thread_id.as_deref())
404        .await?;
405
406    let response = serde_json::json!({
407        "valid": result.valid,
408        "total_records": result.total_records,
409        "verified_records": result.verified_records,
410        "first_broken_at": result.first_broken_at.map(|id| id.to_string()),
411        "error_message": result.error_message,
412        "status": if result.valid { "verified" } else { "integrity_violation" },
413    });
414
415    Ok(Json(response))
416}
417
418/// POST /v1/delegate -- delegate permissions to another agent.
419///
420/// The caller must provide their `agent_id` and must have `Delegate`
421/// permission on the target memories. Without a full auth middleware
422/// this is advisory; production deployments should add an auth layer.
423pub async fn delegate_handler(
424    State(engine): State<AppState>,
425    Json(body): Json<DelegateRequest>,
426) -> Result<Json<serde_json::Value>, AppError> {
427    let permission: Permission = body
428        .permission
429        .parse()
430        .map_err(|e: CoreError| AppError(e))?;
431
432    let caller_agent_id = body
433        .agent_id
434        .unwrap_or_else(|| engine.default_agent_id.clone());
435
436    let scope = if let Some(ref ids) = body.memory_ids {
437        let parsed: std::result::Result<Vec<Uuid>, _> =
438            ids.iter().map(|s| Uuid::parse_str(s)).collect();
439        match parsed {
440            Ok(uuids) => {
441                // Verify caller has Delegate permission on each memory
442                for mid in &uuids {
443                    let has_perm = engine
444                        .storage
445                        .check_permission(*mid, &caller_agent_id, Permission::Delegate)
446                        .await?;
447                    if !has_perm {
448                        return Err(AppError(CoreError::PermissionDenied(format!(
449                            "agent '{}' lacks delegate permission on memory {}",
450                            caller_agent_id, mid
451                        ))));
452                    }
453                }
454                DelegationScope::ByMemoryId(uuids)
455            }
456            Err(e) => {
457                return Err(AppError(CoreError::Validation(format!(
458                    "invalid UUID in memory_ids: {e}"
459                ))));
460            }
461        }
462    } else if let Some(ref tags) = body.tags {
463        DelegationScope::ByTag(tags.clone())
464    } else {
465        DelegationScope::AllMemories
466    };
467
468    let now = chrono::Utc::now();
469    let expires_at = body
470        .expires_in_hours
471        .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
472
473    let delegation = Delegation {
474        id: Uuid::now_v7(),
475        delegator_id: caller_agent_id,
476        delegate_id: body.delegate_id.clone(),
477        permission,
478        scope,
479        max_depth: body.max_depth.unwrap_or(0),
480        current_depth: 0,
481        parent_delegation_id: None,
482        created_at: now.to_rfc3339(),
483        expires_at,
484        revoked_at: None,
485    };
486
487    engine.storage.insert_delegation(&delegation).await?;
488
489    let response = serde_json::json!({
490        "delegation_id": delegation.id.to_string(),
491        "delegator": delegation.delegator_id,
492        "delegate": delegation.delegate_id,
493        "permission": delegation.permission.to_string(),
494        "status": "delegated",
495    });
496
497    Ok(Json(response))
498}
499
500/// GET /v1/health
501pub async fn health_handler() -> Json<serde_json::Value> {
502    Json(serde_json::json!({"status": "ok"}))
503}
504
505// ---------------------------------------------------------------------------
506// GenAI semantic convention helpers
507// ---------------------------------------------------------------------------
508
509struct GenAiFields {
510    event_type: EventType,
511    model: Option<String>,
512    tokens_input: Option<i64>,
513    tokens_output: Option<i64>,
514    cost_usd: Option<f64>,
515}
516
517/// Extract GenAI semantic convention fields from OTLP span attributes.
518/// See: <https://opentelemetry.io/docs/specs/semconv/gen-ai/>
519fn extract_genai_fields(span: &serde_json::Value) -> GenAiFields {
520    let attributes = span.get("attributes").and_then(|v| v.as_array());
521
522    let mut model = None;
523    let mut tokens_input = None;
524    let mut tokens_output = None;
525    let mut cost_usd = None;
526    let mut operation_name = None;
527
528    if let Some(attrs) = attributes {
529        for attr in attrs {
530            let key = match attr.get("key").and_then(|k| k.as_str()) {
531                Some(k) => k,
532                None => continue,
533            };
534            let value = attr.get("value");
535
536            match key {
537                "gen_ai.request.model" => {
538                    model = value
539                        .and_then(|v| v.get("stringValue"))
540                        .and_then(|v| v.as_str())
541                        .map(|s| s.to_string());
542                }
543                "gen_ai.usage.input_tokens" => {
544                    tokens_input = value.and_then(|v| v.get("intValue")).and_then(|v| {
545                        v.as_str()
546                            .and_then(|s| s.parse::<i64>().ok())
547                            .or_else(|| v.as_i64())
548                    });
549                }
550                "gen_ai.usage.output_tokens" => {
551                    tokens_output = value.and_then(|v| v.get("intValue")).and_then(|v| {
552                        v.as_str()
553                            .and_then(|s| s.parse::<i64>().ok())
554                            .or_else(|| v.as_i64())
555                    });
556                }
557                "gen_ai.usage.cost" => {
558                    cost_usd = value
559                        .and_then(|v| v.get("doubleValue"))
560                        .and_then(|v| v.as_f64());
561                }
562                "gen_ai.operation.name" => {
563                    operation_name = value
564                        .and_then(|v| v.get("stringValue"))
565                        .and_then(|v| v.as_str())
566                        .map(|s| s.to_string());
567                }
568                _ => {}
569            }
570        }
571    }
572
573    // If no operation_name from attributes, fall back to span name.
574    let op = operation_name.or_else(|| {
575        span.get("name")
576            .and_then(|v| v.as_str())
577            .map(|s| s.to_string())
578    });
579
580    // Map operation name to EventType.
581    let event_type = match op.as_deref() {
582        Some(s) if s.contains("chat") => EventType::AssistantMessage,
583        Some(s) if s.contains("embed") => EventType::RetrievalQuery,
584        Some(s) if s.contains("tool") => EventType::ToolCall,
585        _ => EventType::ToolCall, // default
586    };
587
588    GenAiFields {
589        event_type,
590        model,
591        tokens_input,
592        tokens_output,
593        cost_usd,
594    }
595}
596
597/// POST /v1/ingest/otlp -- ingest simplified OTLP JSON spans as agent events.
598pub async fn otlp_ingest_handler(
599    State(engine): State<AppState>,
600    Json(body): Json<serde_json::Value>,
601) -> Result<Json<serde_json::Value>, AppError> {
602    let resource_spans = body
603        .get("resourceSpans")
604        .and_then(|v| v.as_array())
605        .cloned()
606        .unwrap_or_default();
607
608    let mut count: u64 = 0;
609
610    for rs in &resource_spans {
611        // Extract agent_id from resource attributes (service.name or agent.id).
612        let resource_agent_id = rs
613            .get("resource")
614            .and_then(|r| r.get("attributes"))
615            .and_then(|attrs| attrs.as_array())
616            .and_then(|attrs| {
617                attrs.iter().find_map(|attr| {
618                    let key = attr.get("key")?.as_str()?;
619                    if key == "agent.id" || key == "service.name" {
620                        attr.get("value")
621                            .and_then(|v| v.get("stringValue"))
622                            .and_then(|v| v.as_str())
623                            .map(|s| s.to_string())
624                    } else {
625                        None
626                    }
627                })
628            });
629
630        let scope_spans = rs
631            .get("scopeSpans")
632            .and_then(|v| v.as_array())
633            .cloned()
634            .unwrap_or_default();
635
636        for ss in &scope_spans {
637            let spans = ss
638                .get("spans")
639                .and_then(|v| v.as_array())
640                .cloned()
641                .unwrap_or_default();
642
643            for span in &spans {
644                let trace_id = span
645                    .get("traceId")
646                    .and_then(|v| v.as_str())
647                    .map(|s| s.to_string());
648
649                let span_id = span
650                    .get("spanId")
651                    .and_then(|v| v.as_str())
652                    .map(|s| s.to_string());
653
654                let agent_id = resource_agent_id
655                    .clone()
656                    .unwrap_or_else(|| engine.default_agent_id.clone());
657
658                // Compute latency from start/end nanosecond timestamps.
659                // OTLP encodes nanos as either JSON strings or integers.
660                let start_nano: u64 = span
661                    .get("startTimeUnixNano")
662                    .and_then(|v| {
663                        v.as_str()
664                            .and_then(|s| s.parse::<u64>().ok())
665                            .or_else(|| v.as_u64())
666                    })
667                    .unwrap_or(0);
668
669                let end_nano: u64 = span
670                    .get("endTimeUnixNano")
671                    .and_then(|v| {
672                        v.as_str()
673                            .and_then(|s| s.parse::<u64>().ok())
674                            .or_else(|| v.as_u64())
675                    })
676                    .unwrap_or(0);
677
678                let latency_ms = if end_nano > start_nano {
679                    Some(((end_nano - start_nano) / 1_000_000) as i64)
680                } else {
681                    None
682                };
683
684                // Convert startTimeUnixNano to RFC3339 timestamp.
685                let timestamp = if start_nano > 0 {
686                    let secs = (start_nano / 1_000_000_000) as i64;
687                    let nsecs = (start_nano % 1_000_000_000) as u32;
688                    chrono::DateTime::from_timestamp(secs, nsecs)
689                        .map(|dt| dt.to_rfc3339())
690                        .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
691                } else {
692                    chrono::Utc::now().to_rfc3339()
693                };
694
695                // Collect span attributes as the event payload.
696                let payload = span
697                    .get("attributes")
698                    .cloned()
699                    .unwrap_or(serde_json::json!({}));
700
701                let genai = extract_genai_fields(span);
702
703                let content_hash =
704                    compute_content_hash(&payload.to_string(), &agent_id, &timestamp);
705
706                let event = AgentEvent {
707                    id: Uuid::now_v7(),
708                    agent_id,
709                    thread_id: None,
710                    run_id: None,
711                    parent_event_id: None,
712                    event_type: genai.event_type,
713                    payload,
714                    trace_id,
715                    span_id,
716                    model: genai.model,
717                    tokens_input: genai.tokens_input,
718                    tokens_output: genai.tokens_output,
719                    latency_ms,
720                    cost_usd: genai.cost_usd,
721                    timestamp,
722                    logical_clock: 0,
723                    content_hash,
724                    prev_hash: None,
725                    embedding: None,
726                };
727
728                engine.storage.insert_event(&event).await?;
729                count += 1;
730            }
731        }
732    }
733
734    Ok(Json(serde_json::json!({"accepted": count})))
735}
736
737// ---------------------------------------------------------------------------
738// Tests
739// ---------------------------------------------------------------------------
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    #[test]
746    fn test_extract_genai_fields_chat_span() {
747        let span = serde_json::json!({
748            "name": "chat gpt-4",
749            "attributes": [
750                {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4"}},
751                {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "150"}},
752                {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "50"}},
753                {"key": "gen_ai.usage.cost", "value": {"doubleValue": 0.006}},
754                {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}
755            ]
756        });
757        let fields = extract_genai_fields(&span);
758        assert_eq!(fields.event_type, EventType::AssistantMessage);
759        assert_eq!(fields.model.as_deref(), Some("gpt-4"));
760        assert_eq!(fields.tokens_input, Some(150));
761        assert_eq!(fields.tokens_output, Some(50));
762        assert!((fields.cost_usd.unwrap() - 0.006).abs() < 1e-9);
763    }
764
765    #[test]
766    fn test_extract_genai_fields_non_genai_default() {
767        let span = serde_json::json!({
768            "name": "http.request",
769            "attributes": [
770                {"key": "http.method", "value": {"stringValue": "GET"}}
771            ]
772        });
773        let fields = extract_genai_fields(&span);
774        assert_eq!(fields.event_type, EventType::ToolCall);
775        assert!(fields.model.is_none());
776        assert!(fields.tokens_input.is_none());
777    }
778}