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                dialect: None,
84                tags: vec!["format:qwen".to_string()],
85                capabilities: ModelCapabilities::REQUIRES_STRICT_TURNS,
86                ..summary()
87            }),
88            ..Default::default()
89        };
90
91        let ctx = resolve(&catalog, Some("qwen3")).await;
92        assert_eq!(ctx.tags, vec!["format:qwen".to_string()]);
93        assert_eq!(ctx.capabilities, ModelCapabilities::REQUIRES_STRICT_TURNS);
94    }
95
96    #[tokio::test]
97    async fn unknown_model_yields_passthrough() {
98        let catalog = SpyCatalog::default();
99        assert_eq!(
100            resolve(&catalog, Some("ghost")).await,
101            ModelContext::passthrough()
102        );
103    }
104
105    /// A broken catalog must degrade the request, not fail it.
106    #[tokio::test]
107    async fn catalog_error_yields_passthrough() {
108        let catalog = SpyCatalog {
109            fails: true,
110            ..Default::default()
111        };
112        assert_eq!(
113            resolve(&catalog, Some("qwen3")).await,
114            ModelContext::passthrough()
115        );
116    }
117
118    /// The whole path, not a hand-built context: a catalog row carrying a
119    /// launch's `chat_template_caps` observation resolves into a
120    /// [`ModelContext`] whose caps the effort gate actually reads.
121    ///
122    /// This is the wiring the proxy depends on — `chat_completions` resolves
123    /// its context through this function and hands it to `apply` — and it is
124    /// the half that a unit test on the gate cannot see. If `ModelSummary`
125    /// ever stops carrying the caps, or `From<&ModelSummary>` stops copying
126    /// them, the gate silently degrades to "nobody knows" on every request
127    /// and every other test in this arc still passes.
128    #[tokio::test]
129    async fn observed_caps_reach_the_pipeline_through_the_catalog() {
130        let catalog = SpyCatalog {
131            found: Some(ModelSummary {
132                template_caps: Some(crate::domain::TemplateCaps {
133                    supports_reasoning_effort: Some(false),
134                    ..Default::default()
135                }),
136                ..summary()
137            }),
138            ..Default::default()
139        };
140        let ctx = resolve(&catalog, Some("qwen3")).await;
141
142        let mut body = serde_json::json!({
143            "model": "qwen3",
144            "messages": [{"role": "user", "content": "hi"}],
145        });
146        let layers = super::super::SamplingLayers {
147            profile: Some(crate::domain::InferenceConfig {
148                reasoning_effort: Some(crate::domain::ReasoningEffort::High),
149                ..Default::default()
150            }),
151            ..Default::default()
152        };
153        let report =
154            super::super::apply(&mut body, &ctx, &layers, None).expect("the pipeline applies");
155
156        assert!(
157            report.effort_suppressed.is_some(),
158            "the caps never reached the gate: {body}"
159        );
160        assert!(body.get("reasoning_effort").is_none(), "{body}");
161    }
162
163    #[tokio::test]
164    async fn no_model_name_skips_the_catalog_entirely() {
165        let catalog = SpyCatalog {
166            found: Some(summary()),
167            ..Default::default()
168        };
169        assert_eq!(resolve(&catalog, None).await, ModelContext::passthrough());
170        assert_eq!(catalog.lookups.load(Ordering::SeqCst), 0);
171    }
172}