Skip to main content

systemprompt_models/routing/
mod.rs

1//! Request-routing classification.
2//!
3//! [`RouteClassifier`] maps an incoming request path to a [`RouteType`]
4//! (HTML content, API endpoint, static asset, or not-found), drives
5//! analytics-tracking decisions, and yields the [`EventMetadata`] used
6//! to tag emitted events.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use crate::ContentRouting;
12use crate::modules::ApiPaths;
13use std::path::Path;
14use std::sync::Arc;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct EventMetadata {
18    pub event_type: &'static str,
19    pub event_category: &'static str,
20    pub log_module: &'static str,
21}
22
23impl EventMetadata {
24    pub const HTML_CONTENT: Self = Self {
25        event_type: "page_view",
26        event_category: "content",
27        log_module: "page_view",
28    };
29
30    pub const API_REQUEST: Self = Self {
31        event_type: "http_request",
32        event_category: "api",
33        log_module: "http_request",
34    };
35
36    pub const STATIC_ASSET: Self = Self {
37        event_type: "asset_request",
38        event_category: "static",
39        log_module: "asset_request",
40    };
41
42    pub const NOT_FOUND: Self = Self {
43        event_type: "not_found",
44        event_category: "error",
45        log_module: "not_found",
46    };
47
48    // Why: the gateway is mounted at `/v1`, not under `/api`, so it matched no
49    // arm of `classify` and fell through to HTML content — every `/v1/messages`
50    // failure logged as `module="page_view"`, an HTML page view of a JSON API.
51    pub const GATEWAY_REQUEST: Self = Self {
52        event_type: "gateway_request",
53        event_category: "gateway",
54        log_module: "gateway_request",
55    };
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum RouteType {
60    HtmlContent { source: String },
61    ApiEndpoint { category: ApiCategory },
62    StaticAsset { asset_type: AssetType },
63    NotFound,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ApiCategory {
68    Content,
69    Core,
70    Agents,
71    OAuth,
72    Gateway,
73    Other,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum AssetType {
78    JavaScript,
79    Stylesheet,
80    Image,
81    Font,
82    SourceMap,
83    Other,
84}
85
86pub struct RouteClassifier {
87    content_routing: Option<Arc<dyn ContentRouting>>,
88}
89
90impl std::fmt::Debug for RouteClassifier {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("RouteClassifier")
93            .field("content_routing", &self.content_routing.is_some())
94            .finish()
95    }
96}
97
98impl RouteClassifier {
99    pub fn new(content_routing: Option<Arc<dyn ContentRouting>>) -> Self {
100        Self { content_routing }
101    }
102
103    pub fn classify(&self, path: &str, _method: &str) -> RouteType {
104        if Self::is_static_asset_path(path) {
105            return RouteType::StaticAsset {
106                asset_type: Self::determine_asset_type(path),
107            };
108        }
109
110        // Why: the inference gateway mounts at `/v1` (`ApiPaths::GATEWAY_BASE`) and
111        // its public counterpart under `/api`, so it is checked here rather
112        // than left to the `/api` arm below.
113        if path.starts_with(ApiPaths::GATEWAY_BASE)
114            || path.starts_with(ApiPaths::GATEWAY_PUBLIC_BASE)
115        {
116            return RouteType::ApiEndpoint {
117                category: ApiCategory::Gateway,
118            };
119        }
120
121        if path.starts_with(ApiPaths::API_BASE) {
122            return RouteType::ApiEndpoint {
123                category: Self::determine_api_category(path),
124            };
125        }
126
127        if path.starts_with(ApiPaths::TRACK_BASE) {
128            return RouteType::ApiEndpoint {
129                category: ApiCategory::Other,
130            };
131        }
132
133        if let Some(routing) = &self.content_routing {
134            if routing.is_html_page(path) {
135                return RouteType::HtmlContent {
136                    source: routing.determine_source(path),
137                };
138            }
139        } else if !Self::is_static_asset_path(path) && !path.starts_with(ApiPaths::API_BASE) {
140            return RouteType::HtmlContent {
141                source: "unknown".to_owned(),
142            };
143        }
144
145        RouteType::NotFound
146    }
147
148    pub fn should_track_analytics(&self, path: &str, method: &str) -> bool {
149        if method == "OPTIONS" {
150            return false;
151        }
152
153        match self.classify(path, method) {
154            RouteType::HtmlContent { .. } => true,
155            // Why: `Gateway` is deliberately absent. Every inference call
156            // already lands a row in `ai_requests` with identity, model, tokens
157            // and cost; counting it again as web analytics double-counts the
158            // one surface that has the better record of itself.
159            RouteType::ApiEndpoint { category } => {
160                matches!(
161                    category,
162                    ApiCategory::Core | ApiCategory::Content | ApiCategory::Other
163                )
164            },
165            RouteType::StaticAsset { .. } | RouteType::NotFound => false,
166        }
167    }
168
169    pub fn is_html(&self, path: &str) -> bool {
170        matches!(self.classify(path, "GET"), RouteType::HtmlContent { .. })
171    }
172
173    pub fn get_event_metadata(&self, path: &str, method: &str) -> EventMetadata {
174        match self.classify(path, method) {
175            RouteType::HtmlContent { .. } => EventMetadata::HTML_CONTENT,
176            RouteType::ApiEndpoint {
177                category: ApiCategory::Gateway,
178            } => EventMetadata::GATEWAY_REQUEST,
179            RouteType::ApiEndpoint { .. } => EventMetadata::API_REQUEST,
180            RouteType::StaticAsset { .. } => EventMetadata::STATIC_ASSET,
181            RouteType::NotFound => EventMetadata::NOT_FOUND,
182        }
183    }
184
185    fn is_static_asset_path(path: &str) -> bool {
186        if path.starts_with(ApiPaths::ASSETS_BASE)
187            || path.starts_with(ApiPaths::WELLKNOWN_BASE)
188            || path.starts_with(ApiPaths::GENERATED_BASE)
189            || path.starts_with(ApiPaths::FILES_BASE)
190        {
191            return true;
192        }
193
194        matches!(
195            Path::new(path).extension().and_then(|e| e.to_str()),
196            Some(
197                "js" | "css"
198                    | "map"
199                    | "ttf"
200                    | "woff"
201                    | "woff2"
202                    | "otf"
203                    | "png"
204                    | "jpg"
205                    | "jpeg"
206                    | "svg"
207                    | "ico"
208                    | "webp"
209                    | "mp4"
210                    | "webm"
211            )
212        ) || path == "/favicon.ico"
213    }
214
215    pub(crate) fn determine_asset_type(path: &str) -> AssetType {
216        match Path::new(path).extension().and_then(|e| e.to_str()) {
217            Some("js") => AssetType::JavaScript,
218            Some("css") => AssetType::Stylesheet,
219            Some("png" | "jpg" | "jpeg" | "svg" | "ico" | "webp") => AssetType::Image,
220            Some("ttf" | "woff" | "woff2" | "otf") => AssetType::Font,
221            Some("map") => AssetType::SourceMap,
222            _ => AssetType::Other,
223        }
224    }
225
226    fn determine_api_category(path: &str) -> ApiCategory {
227        if path.starts_with(ApiPaths::CONTENT_BASE) {
228            ApiCategory::Content
229        } else if path.starts_with(ApiPaths::CORE_BASE) {
230            ApiCategory::Core
231        } else if path.starts_with(ApiPaths::AGENTS_BASE) {
232            ApiCategory::Agents
233        } else if path.starts_with(ApiPaths::OAUTH_BASE) {
234            ApiCategory::OAuth
235        } else {
236            ApiCategory::Other
237        }
238    }
239}