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;