Skip to main content

gglib_core/request_pipeline/
profile_route.rs

1//! Routing of `{model}:{profile}` request ids.
2//!
3//! A client selects a named sampling profile by suffixing the model it asks
4//! for — `qwen3.6:coding`. This module decides, for one requested id, whether
5//! that id names a model outright, a model plus a configured profile, or a
6//! profile that does not exist.
7//!
8//! # Why the catalog decides, not a pattern
9//!
10//! Colons are legitimate inside model names: Ollama-style `name:tag` ids are
11//! everywhere, so `qwen3.6:27b` may well *be* a model. A purely lexical rule
12//! cannot tell that apart from `qwen3.6:coding` naming a profile, so this
13//! module asks the catalog instead. A full-string catalog hit always wins,
14//! which means adding profiles can never shadow a model that already exists.
15//!
16//! # Why an unmatched suffix is an error
17//!
18//! When the suffix matches no profile and the *base* is a real model, the
19//! request is not forwarded — it fails with 404. Silently falling back to the
20//! bare model is the dangerous option: a coding agent whose profile was
21//! renamed or deleted would keep working while quietly sampling at the wrong
22//! temperature, which is exactly the failure this feature exists to prevent. A
23//! loud 404 at the moment of the rename is far cheaper to diagnose.
24//!
25//! That branch cannot distinguish a deleted profile from a model tag that was
26//! never in the catalog (`qwen3.6:27b` with no such model *and* no `27b`
27//! profile), so the error names both readings rather than guessing.
28//!
29//! # Cost
30//!
31//! An id with no `:` returns immediately with no catalog access at all, which
32//! is every request from a client that does not use profiles. Only
33//! colon-bearing ids reach the catalog.
34//!
35//! # Why this runs before the pipeline, not inside it
36//!
37//! [`apply`](super::apply()) is the request pipeline, and its stages all shape a
38//! request that is already known to belong to some model. This does not: it
39//! decides *which* model the request names, which is the question
40//! [`resolve`](super::resolve()) needs answered before it can build a
41//! [`ModelContext`](super::ModelContext) at all. So it sits ahead of the
42//! pipeline rather than in it — a caller routes first, then resolves the base
43//! name it gets back, then applies.
44//!
45//! It lives in `gglib-core` rather than beside the proxy that first needed it
46//! because the CLI selects profiles too, and `gglib-core` is the only crate
47//! both can reach.
48
49use crate::domain::InferenceProfile;
50use crate::ports::ModelCatalogPort;
51use tracing::{debug, warn};
52
53/// What a requested model id turned out to mean.
54#[derive(Debug, Clone, PartialEq)]
55pub enum ModelRoute<'a> {
56    /// The id names a model directly; no profile applies.
57    ///
58    /// Also the outcome when neither the full id nor its base resolves — the
59    /// request continues to the normal model-not-found path rather than being
60    /// second-guessed here.
61    Bare(&'a str),
62
63    /// The id named a model plus a configured profile.
64    Profiled {
65        /// The base model name, with the profile suffix removed.
66        model: &'a str,
67        /// The selected profile.
68        profile: &'a InferenceProfile,
69    },
70
71    /// The base names a real model but the suffix matches no configured
72    /// profile.
73    ProfileNotFound {
74        /// The full id as requested, for the error message.
75        requested: &'a str,
76        /// The suffix that failed to match.
77        suffix: &'a str,
78    },
79}
80
81/// Resolve a requested model id into a [`ModelRoute`].
82///
83/// Resolution order, first match wins:
84///
85/// 1. No `:` in the id — [`ModelRoute::Bare`], without touching the catalog.
86/// 2. The full id resolves in the catalog — [`ModelRoute::Bare`]. A real model
87///    whose name contains a colon always beats a profile reading.
88/// 3. The suffix after the last `:` matches a configured profile —
89///    [`ModelRoute::Profiled`].
90/// 4. The base resolves in the catalog — [`ModelRoute::ProfileNotFound`].
91/// 5. Otherwise [`ModelRoute::Bare`], leaving the existing model-not-found
92///    path to report it.
93///
94/// Splitting on the *last* colon lets a colon-bearing model name still carry a
95/// profile (`qwen:27b:coding`).
96///
97/// Catalog errors are treated as "not found" and logged: a degraded catalog
98/// should not turn into a hard failure on a request that may not need a profile
99/// at all.
100pub async fn resolve_route<'a>(
101    requested: &'a str,
102    profiles: &'a [InferenceProfile],
103    catalog: &dyn ModelCatalogPort,
104) -> ModelRoute<'a> {
105    // 1. The overwhelmingly common case: no profile suffix, no catalog access.
106    let Some((base, suffix)) = requested.rsplit_once(':') else {
107        return ModelRoute::Bare(requested);
108    };
109
110    // 2. A model that genuinely owns this name wins outright.
111    if model_exists(catalog, requested).await {
112        return ModelRoute::Bare(requested);
113    }
114
115    // 3. A configured profile.
116    if let Some(profile) = profiles.iter().find(|p| p.name == suffix) {
117        debug!(model = %base, profile = %suffix, "resolved model:profile request");
118        return ModelRoute::Profiled {
119            model: base,
120            profile,
121        };
122    }
123
124    // 4. Base is real, suffix means nothing — fail loudly rather than sample
125    //    at the wrong temperature without saying so.
126    if model_exists(catalog, base).await {
127        warn!(
128            requested = %requested,
129            suffix = %suffix,
130            "request names no configured profile; rejecting rather than falling back"
131        );
132        return ModelRoute::ProfileNotFound { requested, suffix };
133    }
134
135    // 5. Nothing matched. Let the normal model-not-found path speak.
136    ModelRoute::Bare(requested)
137}
138
139/// Whether `name` resolves in the catalog, treating a query failure as absent.
140async fn model_exists(catalog: &dyn ModelCatalogPort, name: &str) -> bool {
141    match catalog.resolve_model(name).await {
142        Ok(found) => found.is_some(),
143        Err(e) => {
144            warn!(model = %name, error = %e, "catalog lookup failed during profile routing");
145            false
146        }
147    }
148}
149
150#[cfg(test)]
151#[path = "profile_route_tests.rs"]
152mod profile_route_tests;