Skip to main content

gglib_core/request_pipeline/
resolve.rs

1//! The single catalog round-trip that produces a [`ModelContext`].
2
3use tracing::{debug, warn};
4
5use super::ModelContext;
6use crate::ports::ModelCatalogPort;
7
8/// Resolve the [`ModelContext`] for a model in one catalog round-trip.
9///
10/// `model` is `None` when the caller has no model to name — an agent session
11/// against an already-running server, say. That yields a passthrough context
12/// without touching the catalog, which is why every caller can hand its
13/// `Option` straight in rather than open-coding the empty case.
14///
15/// Any failure to resolve returns [`ModelContext::passthrough`]: an unknown or
16/// unreachable model costs the request its model-specific handling, never the
17/// request itself. The two failure modes are logged differently on purpose —
18/// an unknown model is routine (clients name models the catalog has never
19/// heard of), while a catalog error means something is actually broken.
20pub async fn resolve(catalog: &dyn ModelCatalogPort, model: Option<&str>) -> ModelContext {
21    let Some(model_name) = model else {
22        return ModelContext::passthrough();
23    };
24
25    match catalog.resolve_model(model_name).await {
26        Ok(Some(summary)) => ModelContext::from(&summary),
27        Ok(None) => {
28            debug!(model = %model_name, "model not found in catalog; using pass-through context");
29            ModelContext::passthrough()
30        }
31        Err(e) => {
32            warn!(model = %model_name, error = %e, "failed to resolve model context; using pass-through context");
33            ModelContext::passthrough()
34        }
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use super::super::tests_support::summary;
41    use super::*;
42    use crate::domain::ModelCapabilities;
43    use crate::ports::{CatalogError, ModelLaunchSpec, ModelSummary};
44    use async_trait::async_trait;
45    use std::sync::atomic::{AtomicUsize, Ordering};
46
47    /// Counts lookups so tests can assert the catalog was never consulted.
48    #[derive(Debug, Default)]
49    struct SpyCatalog {
50        /// `None` → the model is unknown; `Some` → this summary is returned.
51        found: Option<ModelSummary>,
52        /// When set, every lookup fails instead.
53        fails: bool,
54        lookups: AtomicUsize,
55    }
56
57    #[async_trait]
58    impl ModelCatalogPort for SpyCatalog {
59        async fn list_models(&self) -> Result<Vec<ModelSummary>, CatalogError> {
60            unimplemented!("not exercised by these tests")
61        }
62
63        async fn resolve_model(&self, _name: &str) -> Result<Option<ModelSummary>, CatalogError> {
64            self.lookups.fetch_add(1, Ordering::SeqCst);
65            if self.fails {
66                return Err(CatalogError::QueryFailed("catalog is down".into()));
67            }
68            Ok(self.found.clone())
69        }
70
71        async fn resolve_for_launch(
72            &self,
73            _name: &str,
74        ) -> Result<Option<ModelLaunchSpec>, CatalogError> {
75            unimplemented!("not exercised by these tests")
76        }
77    }
78
79    #[tokio::test]
80    async fn found_model_yields_all_three_fields() {
81        let catalog = SpyCatalog {
82            found: Some(ModelSummary {
83                tags: vec!["format:qwen".to_string()],
84                capabilities: ModelCapabilities::REQUIRES_STRICT_TURNS,
85                ..summary()
86            }),
87            ..Default::default()
88        };
89
90        let ctx = resolve(&catalog, Some("qwen3")).await;
91        assert_eq!(ctx.tags, vec!["format:qwen".to_string()]);
92        assert_eq!(ctx.capabilities, ModelCapabilities::REQUIRES_STRICT_TURNS);
93    }
94
95    #[tokio::test]
96    async fn unknown_model_yields_passthrough() {
97        let catalog = SpyCatalog::default();
98        assert_eq!(
99            resolve(&catalog, Some("ghost")).await,
100            ModelContext::passthrough()
101        );
102    }
103
104    /// A broken catalog must degrade the request, not fail it.
105    #[tokio::test]
106    async fn catalog_error_yields_passthrough() {
107        let catalog = SpyCatalog {
108            fails: true,
109            ..Default::default()
110        };
111        assert_eq!(
112            resolve(&catalog, Some("qwen3")).await,
113            ModelContext::passthrough()
114        );
115    }
116
117    #[tokio::test]
118    async fn no_model_name_skips_the_catalog_entirely() {
119        let catalog = SpyCatalog {
120            found: Some(summary()),
121            ..Default::default()
122        };
123        assert_eq!(resolve(&catalog, None).await, ModelContext::passthrough());
124        assert_eq!(catalog.lookups.load(Ordering::SeqCst), 0);
125    }
126}