1use 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
25pub 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
53pub 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 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#[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 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;