Skip to main content

lean_ctx/core/ocla/
wire_api.rs

1//! Axum REST projection for the public OCLA wire contract.
2
3use axum::{
4    Json, Router,
5    extract::{Extension, Path},
6    http::StatusCode,
7    routing::{delete, get, post},
8};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use std::{
12    collections::HashMap,
13    sync::{Mutex, OnceLock},
14};
15
16use super::budget::{BudgetLedger, BudgetLimit, BudgetScope};
17use super::capsule::CapsuleStore;
18use super::health::{SystemHealth, check_system_health};
19use super::{
20    CanonicalTokenEnvelopeV1, OCLA_API_VERSION, OclaCapability, OclaCapabilityKind, OclaRegistry,
21};
22use crate::core::a2a::dlq::{DeadLetter, DeadLetterDelivery, DlqScope, DlqStats};
23use crate::core::ocla::wire::decode_envelope;
24
25/// Builds the stateless OCLA REST router for merging into an Axum application.
26pub fn ocla_router() -> Router {
27    Router::new()
28        .route("/ocla/v1/health", get(health))
29        .route("/ocla/v1/capabilities", get(capabilities))
30        .route("/ocla/v1/envelope", post(envelope))
31        .route("/ocla/v1/envelope/batch", post(envelope_batch))
32        .route("/ocla/v1/agents", get(agents))
33        .route("/ocla/v1/metrics", get(metrics))
34        .route("/ocla/v1/ledger/summary", get(ledger_summary))
35        .route("/ocla/v1/budget", post(set_budget))
36        .route(
37            "/ocla/v1/budget/{scope}",
38            get(get_budget).delete(delete_budget),
39        )
40        .route("/ocla/v1/capsule", post(capsule_register))
41        .route("/ocla/v1/capsule/{ref}", get(capsule_resolve))
42        .route("/ocla/v1/capsule/{ref}/fork", post(capsule_fork))
43        .route("/ocla/v1/delivery/check", post(delivery_check))
44        .route("/ocla/v1/delivery/batch-check", post(delivery_batch_check))
45        .route("/v1/delivery/batch-check", post(delivery_batch_check))
46        .route("/ocla/v1/delivery/record", post(delivery_record))
47        .route("/ocla/v1/delivery/stats", get(delivery_stats))
48        .route("/ocla/v1/cache/check", post(cache_check))
49        .route("/ocla/v1/cache/record", post(cache_record))
50        .route("/ocla/v1/cache/batch-check", post(cache_batch_check))
51}
52
53/// Adds the privileged DLQ administration surface for one authenticated scope.
54///
55/// The scope is supplied by trusted server configuration, never by request data.
56pub fn ocla_router_with_dlq(scope: DlqScope) -> Router {
57    ocla_router().merge(
58        Router::new()
59            .route("/ocla/v1/dlq", get(dlq))
60            .route("/ocla/v1/dlq/{id}/retry", post(dlq_retry))
61            .route("/ocla/v1/dlq/{id}", delete(dlq_delete))
62            .layer(Extension(scope)),
63    )
64}
65
66#[derive(Default)]
67struct BudgetStore {
68    ledger: BudgetLedger,
69    limits: HashMap<BudgetScope, BudgetLimit>,
70}
71
72static BUDGET_STORE: OnceLock<Mutex<BudgetStore>> = OnceLock::new();
73
74fn budget_store() -> &'static Mutex<BudgetStore> {
75    BUDGET_STORE.get_or_init(|| Mutex::new(BudgetStore::default()))
76}
77
78pub fn admit_budgeted_request(scope: &str, tokens: u64, usd: f64) -> Result<(), String> {
79    let scope = parse_budget_scope(scope)?;
80    let mut store = budget_store()
81        .lock()
82        .unwrap_or_else(std::sync::PoisonError::into_inner);
83    store
84        .ledger
85        .check_budget_with_cost(&scope, tokens, usd)
86        .map_err(|err| err.to_string())?;
87    store.ledger.record_consumption(&scope, tokens, usd);
88    Ok(())
89}
90
91#[cfg(any(test, feature = "test-support"))]
92pub fn set_test_budget_limit(limit: BudgetLimit) {
93    let mut store = budget_store()
94        .lock()
95        .unwrap_or_else(std::sync::PoisonError::into_inner);
96    store.ledger = BudgetLedger::new();
97    store.limits.clear();
98    store.ledger.set_limit(limit.clone());
99    store.limits.insert(limit.scope.clone(), limit);
100}
101
102#[derive(Debug, Deserialize)]
103struct SetBudgetRequest {
104    scope: String,
105    max_tokens_per_day: u64,
106    max_usd_per_day: f64,
107}
108
109#[derive(Serialize)]
110struct BudgetResponse {
111    scope: String,
112    max_tokens_per_day: u64,
113    max_usd_per_day: f64,
114    consumed_tokens: u64,
115    consumed_usd: f64,
116}
117
118fn parse_budget_scope(raw: &str) -> Result<BudgetScope, String> {
119    let (kind, name) = raw
120        .split_once(':')
121        .ok_or_else(|| "scope must use org:name, team:name, or user:name".to_string())?;
122    if name.is_empty() || name.contains(':') {
123        return Err("scope name must be non-empty and contain no ':'".to_string());
124    }
125    match kind {
126        "org" => Ok(BudgetScope::Org(name.to_string())),
127        "team" => Ok(BudgetScope::Team(name.to_string())),
128        "user" => Ok(BudgetScope::User(name.to_string())),
129        _ => Err("scope must use org:name, team:name, or user:name".to_string()),
130    }
131}
132
133fn budget_scope_name(scope: &BudgetScope) -> String {
134    match scope {
135        BudgetScope::Org(name) => format!("org:{name}"),
136        BudgetScope::Team(name) => format!("team:{name}"),
137        BudgetScope::User(name) => format!("user:{name}"),
138    }
139}
140
141fn budget_response(
142    scope: &BudgetScope,
143    limit: &BudgetLimit,
144    ledger: &BudgetLedger,
145) -> BudgetResponse {
146    BudgetResponse {
147        scope: budget_scope_name(scope),
148        max_tokens_per_day: limit.max_tokens_per_day,
149        max_usd_per_day: limit.max_usd_per_day,
150        consumed_tokens: ledger.consumed_tokens(scope),
151        consumed_usd: ledger.consumed_usd(scope),
152    }
153}
154
155async fn set_budget(
156    Json(request): Json<SetBudgetRequest>,
157) -> Result<Json<BudgetResponse>, (StatusCode, Json<Value>)> {
158    if !request.max_usd_per_day.is_finite() || request.max_usd_per_day < 0.0 {
159        return Err(invalid_request(
160            "max_usd_per_day must be finite and non-negative",
161        ));
162    }
163    let scope = parse_budget_scope(&request.scope).map_err(invalid_request)?;
164    let limit = BudgetLimit {
165        scope: scope.clone(),
166        max_tokens_per_day: request.max_tokens_per_day,
167        max_usd_per_day: request.max_usd_per_day,
168    };
169    let mut store = budget_store()
170        .lock()
171        .unwrap_or_else(std::sync::PoisonError::into_inner);
172    store.ledger.set_limit(limit.clone());
173    store.limits.insert(scope.clone(), limit.clone());
174    Ok(Json(budget_response(&scope, &limit, &store.ledger)))
175}
176
177async fn get_budget(
178    Path(raw_scope): Path<String>,
179) -> Result<Json<BudgetResponse>, (StatusCode, Json<Value>)> {
180    let scope = parse_budget_scope(&raw_scope).map_err(invalid_request)?;
181    let store = budget_store()
182        .lock()
183        .unwrap_or_else(std::sync::PoisonError::into_inner);
184    let Some(limit) = store.limits.get(&scope) else {
185        return Err((
186            StatusCode::NOT_FOUND,
187            Json(json!({"error": "budget not found"})),
188        ));
189    };
190    Ok(Json(budget_response(&scope, limit, &store.ledger)))
191}
192
193async fn delete_budget(
194    Path(raw_scope): Path<String>,
195) -> Result<StatusCode, (StatusCode, Json<Value>)> {
196    let scope = parse_budget_scope(&raw_scope).map_err(invalid_request)?;
197    let mut store = budget_store()
198        .lock()
199        .unwrap_or_else(std::sync::PoisonError::into_inner);
200    if store.limits.remove(&scope).is_none() {
201        return Err((
202            StatusCode::NOT_FOUND,
203            Json(json!({"error": "budget not found"})),
204        ));
205    }
206    Ok(StatusCode::NO_CONTENT)
207}
208
209async fn health() -> Result<Json<SystemHealth>, (StatusCode, Json<Value>)> {
210    tokio::task::spawn_blocking(check_system_health)
211        .await
212        .map(Json)
213        .map_err(service_unavailable)
214}
215
216#[derive(Serialize)]
217struct CapabilitiesResponse {
218    version: &'static str,
219    capabilities: Vec<OclaCapability>,
220}
221
222async fn capabilities() -> Json<CapabilitiesResponse> {
223    let registry = OclaRegistry::global();
224    let capabilities = vec![
225        registry.observation_hook.capability(),
226        registry.usage_sink.capability(),
227        registry.metrics_exporter.capability(),
228        registry.savings_ledger.capability(),
229        registry.intent_classifier.capability(),
230        registry.outcome_tracker.capability(),
231        registry.compression_provider.capability(),
232        registry.response_optimizer.capability(),
233        registry.efficiency_analyzer.capability(),
234        registry.config_tuner.capability(),
235        registry.experiment_runner.capability(),
236        registry.connector_scheduler.capability(),
237        registry.agent_gateway.capability(),
238        registry.delivery_registry.capability(),
239    ];
240    debug_assert_eq!(capabilities.len(), OclaCapabilityKind::ALL.len());
241
242    Json(CapabilitiesResponse {
243        version: OCLA_API_VERSION,
244        capabilities,
245    })
246}
247
248async fn envelope(
249    body: String,
250) -> Result<Json<CanonicalTokenEnvelopeV1>, (StatusCode, Json<Value>)> {
251    decode_envelope(&body).map(Json).map_err(invalid_request)
252}
253
254#[derive(Serialize)]
255struct BatchEnvelopeResult {
256    valid: bool,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    envelope: Option<CanonicalTokenEnvelopeV1>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    error: Option<String>,
261}
262
263async fn envelope_batch(Json(envelopes): Json<Vec<Value>>) -> Json<Vec<BatchEnvelopeResult>> {
264    let results = envelopes
265        .into_iter()
266        .map(|envelope| match serde_json::to_string(&envelope) {
267            Ok(json) => match decode_envelope(&json) {
268                Ok(envelope) => BatchEnvelopeResult {
269                    valid: true,
270                    envelope: Some(envelope),
271                    error: None,
272                },
273                Err(error) => BatchEnvelopeResult {
274                    valid: false,
275                    envelope: None,
276                    error: Some(error.to_string()),
277                },
278            },
279            Err(error) => BatchEnvelopeResult {
280                valid: false,
281                envelope: None,
282                error: Some(error.to_string()),
283            },
284        })
285        .collect();
286    Json(results)
287}
288
289async fn agents() -> Result<Json<serde_json::Value>, (StatusCode, Json<Value>)> {
290    match crate::core::agents::list_unified() {
291        Ok(unified) => Ok(Json(serde_json::to_value(unified).unwrap_or_default())),
292        Err(_) => Err((
293            StatusCode::INTERNAL_SERVER_ERROR,
294            Json(serde_json::json!({ "error": "agent registry unavailable" })),
295        )),
296    }
297}
298
299#[derive(Serialize)]
300struct MetricsResponse {
301    total_events: usize,
302    saved_tokens: u64,
303    saved_usd: f64,
304    trait_adoption_count: usize,
305}
306
307async fn metrics() -> Json<MetricsResponse> {
308    let summary = crate::core::savings_ledger::summary();
309    Json(MetricsResponse {
310        total_events: summary.total_events,
311        saved_tokens: summary.saved_tokens,
312        saved_usd: summary.saved_usd,
313        trait_adoption_count: OclaCapabilityKind::ALL.len(),
314    })
315}
316
317#[derive(Serialize)]
318struct LedgerSummaryResponse {
319    events: usize,
320    tokens: u64,
321    usd: f64,
322}
323
324async fn ledger_summary() -> Json<LedgerSummaryResponse> {
325    let summary = crate::core::savings_ledger::summary();
326    Json(LedgerSummaryResponse {
327        events: summary.total_events,
328        tokens: summary.saved_tokens,
329        usd: summary.saved_usd,
330    })
331}
332
333#[derive(Serialize)]
334struct DlqResponse {
335    dead_letters: Vec<DeadLetter>,
336    stats: DlqStats,
337}
338
339async fn dlq(
340    Extension(scope): Extension<DlqScope>,
341) -> Result<Json<DlqResponse>, (StatusCode, Json<Value>)> {
342    let queue = super::health::dead_letter_queue().clone();
343    let snapshot = tokio::task::spawn_blocking(move || {
344        Ok::<_, super::OclaError>((queue.peek(&scope)?, queue.stats(Some(&scope))?))
345    })
346    .await
347    .map_err(service_unavailable)?
348    .map_err(service_unavailable)?;
349    Ok(Json(DlqResponse {
350        dead_letters: snapshot.0,
351        stats: snapshot.1,
352    }))
353}
354
355async fn dlq_retry(
356    Path(id): Path<String>,
357    Extension(scope): Extension<DlqScope>,
358) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
359    let queue = super::health::dead_letter_queue().clone();
360    let lock_queue = queue.clone();
361    let lock_scope = scope.clone();
362    let lock_id = id.clone();
363    let _retry_guard =
364        tokio::task::spawn_blocking(move || lock_queue.lock_retry(&lock_scope, &lock_id))
365            .await
366            .map_err(service_unavailable)?
367            .map_err(service_unavailable)?;
368    let lookup_scope = scope.clone();
369    let lookup_id = id.clone();
370    let letter = tokio::task::spawn_blocking(move || queue.get(&lookup_scope, &lookup_id))
371        .await
372        .map_err(service_unavailable)?
373        .map_err(service_unavailable)?
374        .ok_or_else(|| invalid_request(format!("dead letter not found: {id}")))?;
375
376    match letter.delivery {
377        DeadLetterDelivery::LocalAgentBus => {
378            let queue = super::health::dead_letter_queue().clone();
379            let retry_scope = scope.clone();
380            let retry_id = id.clone();
381            tokio::task::spawn_blocking(move || queue.retry(&retry_scope, &retry_id))
382                .await
383                .map_err(service_unavailable)?
384                .map_err(invalid_request)?;
385        }
386        DeadLetterDelivery::RemoteHttp { .. } => {
387            let transport = tokio::task::spawn_blocking(
388                super::builtin::agent_gateway::BuiltinAgentGateway::load_remote_transport,
389            )
390            .await
391            .map_err(service_unavailable)?
392            .ok_or_else(|| invalid_request("no authenticated remote transport configured"))?;
393            transport
394                .retry_dead_letter(&letter)
395                .await
396                .map_err(invalid_request)?;
397            let queue = super::health::dead_letter_queue().clone();
398            let delete_scope = scope.clone();
399            let delete_id = id.clone();
400            tokio::task::spawn_blocking(move || queue.dequeue(&delete_scope, &delete_id))
401                .await
402                .map_err(service_unavailable)?
403                .map_err(service_unavailable)?;
404        }
405    }
406    Ok(Json(json!({"id": id, "retried": true})))
407}
408
409async fn dlq_delete(
410    Path(id): Path<String>,
411    Extension(scope): Extension<DlqScope>,
412) -> Result<StatusCode, (StatusCode, Json<Value>)> {
413    let queue = super::health::dead_letter_queue().clone();
414    let delete_id = id.clone();
415    if tokio::task::spawn_blocking(move || queue.dequeue(&scope, &delete_id))
416        .await
417        .map_err(service_unavailable)?
418        .map_err(service_unavailable)?
419        .is_some()
420    {
421        Ok(StatusCode::NO_CONTENT)
422    } else {
423        Err((
424            StatusCode::NOT_FOUND,
425            Json(json!({"error": format!("dead letter not found: {id}")})),
426        ))
427    }
428}
429
430fn service_unavailable(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
431    tracing::warn!(error = %error, "DLQ store unavailable");
432    (
433        StatusCode::SERVICE_UNAVAILABLE,
434        Json(json!({"error": "dead letter store unavailable"})),
435    )
436}
437
438fn invalid_request(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
439    (
440        StatusCode::BAD_REQUEST,
441        Json(json!({"error": error.to_string()})),
442    )
443}
444
445static CAPSULE_STORE: OnceLock<CapsuleStore> = OnceLock::new();
446
447fn capsule_store() -> &'static CapsuleStore {
448    CAPSULE_STORE.get_or_init(CapsuleStore::new)
449}
450
451async fn capsule_register(body: String) -> (StatusCode, Json<Value>) {
452    let capsule_ref = capsule_store().register(body.as_bytes());
453    (
454        StatusCode::CREATED,
455        Json(json!({"capsule_ref": capsule_ref})),
456    )
457}
458
459async fn capsule_resolve(Path(capsule_ref): Path<String>) -> (StatusCode, Json<Value>) {
460    match capsule_store().resolve(&capsule_ref) {
461        Ok(data) => {
462            let text = String::from_utf8_lossy(&data);
463            (
464                StatusCode::OK,
465                Json(json!({"capsule_ref": capsule_ref, "data": text})),
466            )
467        }
468        Err(_) => (
469            StatusCode::NOT_FOUND,
470            Json(json!({"error": "capsule not found"})),
471        ),
472    }
473}
474
475#[derive(Deserialize)]
476struct ForkRequest {
477    budget_tokens: u64,
478}
479
480async fn capsule_fork(
481    Path(capsule_ref): Path<String>,
482    Json(req): Json<ForkRequest>,
483) -> (StatusCode, Json<Value>) {
484    match capsule_store().fork(&capsule_ref, req.budget_tokens) {
485        Ok(child_ref) => (StatusCode::CREATED, Json(json!({"capsule_ref": child_ref}))),
486        Err(_) => (
487            StatusCode::NOT_FOUND,
488            Json(json!({"error": "parent capsule not found"})),
489        ),
490    }
491}
492
493#[derive(Deserialize)]
494struct DeliveryCheckRequest {
495    blake3: [u8; 12],
496    mtime: u64,
497    #[serde(deserialize_with = "deserialize_delivery_path")]
498    path: String,
499    requester_agent_id: Option<String>,
500    requester_conversation_id: Option<String>,
501}
502
503fn deserialize_delivery_path<'de, D: serde::Deserializer<'de>>(
504    deserializer: D,
505) -> Result<String, D::Error> {
506    let path = String::deserialize(deserializer)?;
507    if path.trim().is_empty() || path.len() > 4096 || path.contains('\0') {
508        return Err(serde::de::Error::custom(
509            "a bounded delivery path is required",
510        ));
511    }
512    Ok(path)
513}
514
515#[derive(Deserialize)]
516struct DeliveryBatchCheckRequest {
517    checks: Vec<DeliveryCheckRequest>,
518}
519
520#[derive(Serialize)]
521struct DeliveryBatchCheckResult {
522    hit: bool,
523    record: Option<crate::core::ocla::types::DeliveryRecord>,
524}
525
526#[derive(Serialize)]
527struct DeliveryBatchCheckResponse {
528    results: Vec<DeliveryBatchCheckResult>,
529}
530
531async fn delivery_check(Json(req): Json<DeliveryCheckRequest>) -> (StatusCode, Json<Value>) {
532    let reg = OclaRegistry::global();
533    delivery_check_with_registry(reg.delivery_registry.as_ref(), &req)
534}
535
536fn delivery_check_with_registry(
537    registry: &dyn super::traits::DeliveryRegistry,
538    req: &DeliveryCheckRequest,
539) -> (StatusCode, Json<Value>) {
540    // Lookup is not delivery. Only the serving path knows the actual output
541    // token count and may record estimated savings after producing the response.
542    match registry.check_delivery(
543        &req.blake3,
544        req.mtime,
545        &req.path,
546        req.requester_agent_id.as_deref(),
547        req.requester_conversation_id.as_deref(),
548    ) {
549        Some(record) => (
550            StatusCode::OK,
551            Json(json!({
552                "hit": true,
553                "path": record.path,
554                "line_count": record.line_count,
555                "token_count": record.token_count,
556                "agent_id": record.agent_id,
557                "conversation_id": record.conversation_id,
558                "read_at": record.read_at,
559                "fresh": record.fresh,
560                "relay_content": record.relay_content,
561                "relay_mode": record.relay_mode,
562            })),
563        ),
564        None => (StatusCode::OK, Json(json!({"hit": false}))),
565    }
566}
567async fn delivery_batch_check(
568    Json(request): Json<DeliveryBatchCheckRequest>,
569) -> Json<DeliveryBatchCheckResponse> {
570    let reg = OclaRegistry::global();
571    let results = request
572        .checks
573        .into_iter()
574        .map(|check| {
575            let record = reg.delivery_registry.check_delivery(
576                &check.blake3,
577                check.mtime,
578                &check.path,
579                check.requester_agent_id.as_deref(),
580                check.requester_conversation_id.as_deref(),
581            );
582            if let Some(record) = record {
583                DeliveryBatchCheckResult {
584                    hit: true,
585                    record: Some(record),
586                }
587            } else {
588                DeliveryBatchCheckResult {
589                    hit: false,
590                    record: None,
591                }
592            }
593        })
594        .collect();
595    Json(DeliveryBatchCheckResponse { results })
596}
597
598async fn delivery_record(
599    Json(entry): Json<crate::core::ocla::types::DeliveryEntry>,
600) -> (StatusCode, Json<Value>) {
601    if entry.access.is_some() {
602        return (
603            StatusCode::FORBIDDEN,
604            Json(json!({"error":"scoped delivery requires authenticated scope authority"})),
605        );
606    }
607    let reg = OclaRegistry::global();
608    (
609        StatusCode::OK,
610        Json(json!(reg.delivery_registry.record_delivery(entry))),
611    )
612}
613
614async fn delivery_stats() -> Json<Value> {
615    let reg = OclaRegistry::global();
616    let stats = reg.delivery_registry.delivery_stats();
617    Json(json!({
618        "total_entries": stats.total_entries,
619        "stubs_served": stats.stubs_served,
620        "tokens_saved": stats.tokens_saved,
621        "unique_paths": stats.unique_paths,
622        "unique_agents": stats.unique_agents,
623        "relay_served": stats.relay_served,
624        "relay_tokens_saved": stats.relay_tokens_saved,
625    }))
626}
627
628// ── Generalized cross-agent cache endpoints ──────────────────────────
629
630#[derive(Debug, thiserror::Error)]
631#[error("invalid cache validator")]
632struct InvalidCacheValidator;
633
634fn parse_validator(
635    s: &str,
636) -> Result<crate::core::ocla::cache_types::CacheValidator, InvalidCacheValidator> {
637    use crate::core::ocla::cache_types::CacheValidator;
638    if s == "immutable" {
639        return Ok(CacheValidator::Immutable);
640    }
641    if let Some(ns) = s.strip_prefix("file:") {
642        if let Ok(mtime_ns) = ns.parse::<u128>() {
643            return Ok(CacheValidator::File { mtime_ns });
644        }
645    }
646    if let Some(ns) = s.strip_prefix("directory:") {
647        if let Ok(mtime_ns) = ns.parse::<u128>() {
648            return Ok(CacheValidator::Directory { mtime_ns });
649        }
650    }
651    Err(InvalidCacheValidator)
652}
653
654#[allow(dead_code)]
655fn serialize_validator(v: &crate::core::ocla::cache_types::CacheValidator) -> String {
656    use crate::core::ocla::cache_types::CacheValidator;
657    match v {
658        CacheValidator::Immutable => "immutable".into(),
659        CacheValidator::File { mtime_ns } => format!("file:{mtime_ns}"),
660        CacheValidator::Directory { mtime_ns } => format!("directory:{mtime_ns}"),
661    }
662}
663
664#[derive(Deserialize)]
665struct CacheCheckRequest {
666    key: String,
667    validator: String,
668    requester_agent_id: Option<String>,
669    requester_conversation_id: Option<String>,
670}
671
672fn cache_cross_agent_hit(
673    entry: &crate::core::ocla::cache_types::DeliveryEntryV2,
674    requester_agent_id: Option<&str>,
675    requester_conversation_id: Option<&str>,
676) -> bool {
677    let same_agent = requester_agent_id.is_some_and(|a| a == entry.producer.agent_id);
678    let same_conv = requester_conversation_id.is_some_and(|c| c == entry.producer.conversation_id);
679    if same_agent && same_conv {
680        return false;
681    }
682    crate::core::ocla::cache_delivery::entry_allows_stub(entry, requester_conversation_id)
683}
684
685async fn cache_check(
686    Json(req): Json<CacheCheckRequest>,
687) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
688    let validator = parse_validator(&req.validator).map_err(invalid_request)?;
689    let coordinator = crate::core::ocla::cache_coordinator::materialized_cache();
690    use crate::core::ocla::cache_coordinator::CacheCoordinator;
691    let key = crate::core::ocla::cache_types::CacheKey(req.key);
692    Ok(match coordinator.check(&key, &validator) {
693        Some(entry) => {
694            if cache_cross_agent_hit(
695                &entry,
696                req.requester_agent_id.as_deref(),
697                req.requester_conversation_id.as_deref(),
698            ) {
699                Json(json!({"hit": true, "entry": entry}))
700            } else {
701                Json(json!({"hit": false}))
702            }
703        }
704        None => Json(json!({"hit": false})),
705    })
706}
707
708async fn cache_record(
709    Json(entry): Json<crate::core::ocla::cache_types::DeliveryEntryV2>,
710) -> StatusCode {
711    let coordinator = crate::core::ocla::cache_coordinator::materialized_cache();
712    use crate::core::ocla::cache_coordinator::CacheCoordinator;
713    coordinator.record(entry);
714    StatusCode::NO_CONTENT
715}
716
717#[derive(Deserialize)]
718struct CacheBatchCheckRequest {
719    checks: Vec<CacheCheckRequest>,
720}
721
722#[derive(Serialize)]
723struct CacheBatchCheckResult {
724    hit: bool,
725    entry: Option<crate::core::ocla::cache_types::DeliveryEntryV2>,
726}
727
728async fn cache_batch_check(
729    Json(request): Json<CacheBatchCheckRequest>,
730) -> Result<Json<Vec<CacheBatchCheckResult>>, (StatusCode, Json<Value>)> {
731    // A stale lookup can evict entries, so reject the complete request before
732    // obtaining the coordinator or performing any lookup.
733    let validators = request
734        .checks
735        .iter()
736        .map(|check| parse_validator(&check.validator))
737        .collect::<Result<Vec<_>, _>>()
738        .map_err(invalid_request)?;
739    let coordinator = crate::core::ocla::cache_coordinator::materialized_cache();
740    use crate::core::ocla::cache_coordinator::CacheCoordinator;
741    let results = request
742        .checks
743        .into_iter()
744        .zip(validators)
745        .map(|(check, validator)| {
746            let key = crate::core::ocla::cache_types::CacheKey(check.key);
747            match coordinator.check(&key, &validator) {
748                Some(entry) => {
749                    if cache_cross_agent_hit(
750                        &entry,
751                        check.requester_agent_id.as_deref(),
752                        check.requester_conversation_id.as_deref(),
753                    ) {
754                        CacheBatchCheckResult {
755                            hit: true,
756                            entry: Some(entry),
757                        }
758                    } else {
759                        CacheBatchCheckResult {
760                            hit: false,
761                            entry: None,
762                        }
763                    }
764                }
765                None => CacheBatchCheckResult {
766                    hit: false,
767                    entry: None,
768                },
769            }
770        })
771        .collect();
772    Ok(Json(results))
773}
774
775#[cfg(test)]
776mod tests;