OpenPay (3 crates, ~13k LOC, 175 tests) op-connect sub-merchant onboarding + KYB + split-payments + payouts + 1099-K op-3ds2 3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions) op-tax US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters Smart Byte (3 crates, ~7k LOC, 57 tests) smart-byte-libp2p libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp) smart-byte-crdt LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop smart-byte-keri-witness KERI key-rotation cycle + witness layer + watchers + recovery + delegation EOC (3 crates, ~8k LOC, 108 tests) eoc-tools function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox eoc-multimodal vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router eoc-spec-decode vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests. Co-Authored-By: Claude Opus 4.7 <[email protected]>
221 lines
8.1 KiB
Rust
221 lines
8.1 KiB
Rust
//! Modality routing.
|
|
//!
|
|
//! Routing is the bridge between the multi-modal surface and the
|
|
//! single-model [`NeuralBackend`] trait the cascade expects. Given a
|
|
//! [`MultimodalQuery`] the router inspects the set of [`Modality`]s
|
|
//! present and picks a backend:
|
|
//!
|
|
//! * **text only** → `text_backend`
|
|
//! * **text + image** → `vision_backend`
|
|
//! * **text + audio** → `audio_backend`
|
|
//! * **image + audio** (or anything richer, e.g. with video) → `unified_backend`
|
|
//! if configured, otherwise `vision_backend`
|
|
//!
|
|
//! Each backend is just a [`NeuralBackend`]; the router synthesises a
|
|
//! text [`Query`] from the textual parts of the multi-modal query and
|
|
//! lets the backend's own implementation pick up image / audio data from
|
|
//! query metadata. Vendors that need richer in-band image / audio passing
|
|
//! provide their own `infer_multimodal()` method on the concrete backend
|
|
//! type — the router here is the simple "pick a backend" layer.
|
|
|
|
use eoc_core::{Query, Response};
|
|
use eoc_neural::NeuralBackend;
|
|
|
|
use crate::error::MultimodalResult;
|
|
use crate::modality::{Modality, MultimodalQuery};
|
|
|
|
/// Which backend the router picked.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RouteChoice {
|
|
/// The text-only backend (default for `Modality::Text`).
|
|
Text,
|
|
/// The vision-language backend (`text + image`).
|
|
Vision,
|
|
/// The audio-language backend (`text + audio`).
|
|
Audio,
|
|
/// The unified backend (e.g. Gemini, GPT-4o) for queries with both
|
|
/// image *and* audio, or anything richer (video).
|
|
Unified,
|
|
}
|
|
|
|
/// Routes a [`MultimodalQuery`] to the modality-appropriate
|
|
/// [`NeuralBackend`] and returns a [`Response`].
|
|
pub struct ModalityRouter {
|
|
/// Vision-language backend (image-bearing queries).
|
|
pub vision_backend: Box<dyn NeuralBackend>,
|
|
/// Audio-language backend (audio-bearing queries).
|
|
pub audio_backend: Box<dyn NeuralBackend>,
|
|
/// Text-only backend (fallback for text-only queries).
|
|
pub text_backend: Box<dyn NeuralBackend>,
|
|
/// Unified-modality backend (e.g. Gemini) for image+audio mixed queries.
|
|
/// Falls back to [`Self::vision_backend`] when `None`.
|
|
pub unified_backend: Option<Box<dyn NeuralBackend>>,
|
|
}
|
|
|
|
impl ModalityRouter {
|
|
/// Construct with the three required backends. `unified_backend`
|
|
/// defaults to `None` (routes fall back to vision).
|
|
pub fn new(
|
|
text_backend: Box<dyn NeuralBackend>,
|
|
vision_backend: Box<dyn NeuralBackend>,
|
|
audio_backend: Box<dyn NeuralBackend>,
|
|
) -> Self {
|
|
Self {
|
|
text_backend,
|
|
vision_backend,
|
|
audio_backend,
|
|
unified_backend: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a unified-modality backend.
|
|
pub fn with_unified(mut self, unified: Box<dyn NeuralBackend>) -> Self {
|
|
self.unified_backend = Some(unified);
|
|
self
|
|
}
|
|
|
|
/// Decide which backend handles this query.
|
|
pub fn choose(&self, q: &MultimodalQuery) -> RouteChoice {
|
|
let modalities = q.modalities();
|
|
let has_image =
|
|
modalities.contains(&Modality::Image) || modalities.contains(&Modality::Video);
|
|
let has_audio = modalities.contains(&Modality::Audio);
|
|
|
|
match (has_image, has_audio) {
|
|
(true, true) => RouteChoice::Unified,
|
|
(true, false) => RouteChoice::Vision,
|
|
(false, true) => RouteChoice::Audio,
|
|
(false, false) => RouteChoice::Text,
|
|
}
|
|
}
|
|
|
|
/// Route the query and return a [`Response`]. Joule attribution and
|
|
/// stage tagging come from whichever backend handled the call.
|
|
pub async fn route(&self, q: &MultimodalQuery) -> MultimodalResult<Response> {
|
|
let backend = match self.choose(q) {
|
|
RouteChoice::Text => &self.text_backend,
|
|
RouteChoice::Vision => &self.vision_backend,
|
|
RouteChoice::Audio => &self.audio_backend,
|
|
RouteChoice::Unified => self
|
|
.unified_backend
|
|
.as_ref()
|
|
.unwrap_or(&self.vision_backend),
|
|
};
|
|
// Build a text-only `Query` that preserves the query id derived
|
|
// from the multi-modal payload; backends that need image / audio
|
|
// data should be invoked through their concrete API (e.g.
|
|
// [`crate::vision::OpenAiVisionBackend::infer_multimodal`]). The
|
|
// router here is the simple "which backend?" dispatcher.
|
|
let mut tq = Query::new(q.text_prompt());
|
|
tq.id = q.id;
|
|
tq.metadata = q.metadata.clone();
|
|
Ok(backend.infer(&tq).await)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use async_trait::async_trait;
|
|
use eoc_core::{JouleCost, Stage};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU8, Ordering};
|
|
|
|
#[derive(Default)]
|
|
struct LabelBackend {
|
|
label: &'static str,
|
|
hits: Arc<AtomicU8>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl NeuralBackend for LabelBackend {
|
|
async fn infer(&self, q: &Query) -> Response {
|
|
self.hits.fetch_add(1, Ordering::SeqCst);
|
|
Response::new(
|
|
q.id,
|
|
self.label.to_string(),
|
|
Stage::Neural,
|
|
JouleCost::estimated(1),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn router(unified: bool) -> (ModalityRouter, Arc<AtomicU8>, Arc<AtomicU8>, Arc<AtomicU8>, Arc<AtomicU8>) {
|
|
let (t, v, a, u) = (
|
|
Arc::new(AtomicU8::new(0)),
|
|
Arc::new(AtomicU8::new(0)),
|
|
Arc::new(AtomicU8::new(0)),
|
|
Arc::new(AtomicU8::new(0)),
|
|
);
|
|
let mut router = ModalityRouter::new(
|
|
Box::new(LabelBackend { label: "text", hits: t.clone() }),
|
|
Box::new(LabelBackend { label: "vision", hits: v.clone() }),
|
|
Box::new(LabelBackend { label: "audio", hits: a.clone() }),
|
|
);
|
|
if unified {
|
|
router = router.with_unified(Box::new(LabelBackend {
|
|
label: "unified",
|
|
hits: u.clone(),
|
|
}));
|
|
}
|
|
(router, t, v, a, u)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn text_only_routes_to_text() {
|
|
let (r, t, _, _, _) = router(false);
|
|
let q = MultimodalQuery::text("hi");
|
|
let resp = r.route(&q).await.expect("ok");
|
|
assert_eq!(resp.payload, "text");
|
|
assert_eq!(t.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_query_routes_to_vision() {
|
|
let (r, _, v, _, _) = router(false);
|
|
let q = MultimodalQuery::new(vec![
|
|
crate::modality::QueryPart::Text("describe".to_string()),
|
|
crate::modality::QueryPart::Image(crate::modality::ImageRef::Url(
|
|
"u".to_string(),
|
|
)),
|
|
]);
|
|
let resp = r.route(&q).await.expect("ok");
|
|
assert_eq!(resp.payload, "vision");
|
|
assert_eq!(v.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn audio_query_routes_to_audio() {
|
|
let (r, _, _, a, _) = router(false);
|
|
let q = MultimodalQuery::new(vec![crate::modality::QueryPart::Audio(
|
|
crate::modality::AudioRef::Url("u".to_string()),
|
|
)]);
|
|
let resp = r.route(&q).await.expect("ok");
|
|
assert_eq!(resp.payload, "audio");
|
|
assert_eq!(a.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_plus_audio_routes_to_unified_when_present() {
|
|
let (r, _, _, _, u) = router(true);
|
|
let q = MultimodalQuery::new(vec![
|
|
crate::modality::QueryPart::Image(crate::modality::ImageRef::Url("u".to_string())),
|
|
crate::modality::QueryPart::Audio(crate::modality::AudioRef::Url("u".to_string())),
|
|
]);
|
|
let resp = r.route(&q).await.expect("ok");
|
|
assert_eq!(resp.payload, "unified");
|
|
assert_eq!(u.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_plus_audio_falls_back_to_vision_when_no_unified() {
|
|
let (r, _, v, _, _) = router(false);
|
|
let q = MultimodalQuery::new(vec![
|
|
crate::modality::QueryPart::Image(crate::modality::ImageRef::Url("u".to_string())),
|
|
crate::modality::QueryPart::Audio(crate::modality::AudioRef::Url("u".to_string())),
|
|
]);
|
|
let resp = r.route(&q).await.expect("ok");
|
|
assert_eq!(resp.payload, "vision");
|
|
assert_eq!(v.load(Ordering::SeqCst), 1);
|
|
}
|
|
}
|