Skip to main content

ferrox_api/
routes.rs

1//! Every path `ferrox-server` serves, named once.
2//!
3//! Only routes that actually exist belong here. A constant for a
4//! not-yet-implemented endpoint is worse than no constant at all: it
5//! reads as a promise, and a client that imports it gets a 404 with the
6//! contract crate's blessing.
7
8/// Liveness + readiness + capability handshake. Never behind auth, so a
9/// probe works regardless of `FERROX_API_KEY`.
10pub const HEALTH: &str = "/health";
11
12/// Prometheus text-exposition metrics.
13pub const METRICS: &str = "/metrics";
14
15/// Response- and prefix-cache counters.
16pub const CACHE_STATS: &str = "/cache/stats";
17
18pub const V1_MODELS: &str = "/v1/models";
19pub const V1_CHAT_COMPLETIONS: &str = "/v1/chat/completions";
20pub const V1_COMPLETIONS: &str = "/v1/completions";
21pub const V1_TOKENIZE: &str = "/v1/tokenize";
22pub const V1_DETOKENIZE: &str = "/v1/detokenize";
23pub const V1_EMBEDDINGS: &str = "/v1/embeddings";
24
25/// Anthropic-compatible messages endpoint.
26pub const V1_MESSAGES: &str = "/v1/messages";
27
28/// Explicit cancellation of one in-flight generation, by the
29/// `request_id` the server states on the first streamed chunk.
30///
31/// Under `/v1` rather than at the root the plan sketched it at, for two
32/// reasons that both matter: it acts on inference and so belongs behind
33/// the same `FERROX_API_KEY` gate as the endpoint that started the
34/// work, and `/v1` is where every other inference path already lives,
35/// so a client configured with one base URL reaches all of them.
36///
37/// This is the second tier of cancellation, not the only one -- a
38/// client that simply drops the connection is also honoured. It exists
39/// because the first tier is unreliable: proxies buffer, and a page
40/// unload races the abort it is supposed to send. `keepalive: true`
41/// makes this one survive that.
42pub const V1_CANCEL: &str = "/v1/cancel";
43
44/// Reconnect into a stream started with `stream_resumable: true`,
45/// resuming after the `Last-Event-ID` the client last saw.
46///
47/// **A template, not a literal** -- see [`ADMIN_TASK_CANCEL`] for why
48/// this crate writes placeholders in the OpenAPI style. Build a
49/// concrete path with [`v1_stream`].
50///
51/// Behind the same key as the endpoint that started the work: the
52/// replay buffer holds the model's output, so reading it must cost
53/// exactly what producing it cost.
54pub const V1_STREAM: &str = "/v1/stream/{request_id}";
55
56/// The same replay buffer over plain JSON, for the case SSE cannot
57/// survive: a reverse proxy that buffers `text/event-stream` turns a
58/// stream into one long silence, and cannot do that to a short response
59/// that has already ended. Build a concrete path with
60/// [`v1_stream_poll`].
61pub const V1_STREAM_POLL: &str = "/v1/stream/{request_id}/poll";
62
63// ---------------------------------------------------------------------
64// Control surface.
65//
66// Everything under `/admin` either changes what the server serves or
67// writes to disk, so all of it sits behind the same `FERROX_API_KEY`
68// gate as `/v1/*` -- never on the unauthenticated `/health` side.
69// ---------------------------------------------------------------------
70
71/// Model inventory: what is on disk, what is loaded, what failed.
72pub const ADMIN_MODELS: &str = "/admin/models";
73
74/// Start loading a discovered model by its `id`. Answers `202` with a
75/// task id; the load itself runs off the request.
76pub const ADMIN_MODELS_LOAD: &str = "/admin/models/load";
77
78/// Drop the active model. Synchronous: unloading is releasing one
79/// `Arc`, and requests already decoding keep theirs.
80pub const ADMIN_MODELS_UNLOAD: &str = "/admin/models/unload";
81
82/// Fetch a `.gguf` from the Hugging Face Hub into the model directory.
83/// Answers `202` with a task id.
84pub const ADMIN_DOWNLOAD: &str = "/admin/download";
85
86/// Every long-running job this server knows about, newest first.
87pub const ADMIN_TASKS: &str = "/admin/tasks";
88
89/// Request cancellation of one task. **A template, not a literal**: the
90/// `{task_id}` placeholder is written in the OpenAPI style rather than
91/// any one web framework's, because this crate is imported by clients
92/// that have never heard of the server's router. Build a concrete path
93/// with [`admin_task_cancel`].
94pub const ADMIN_TASK_CANCEL: &str = "/admin/tasks/{task_id}/cancel";
95
96/// Counters, uptime, and the recent-request ring buffer.
97pub const ADMIN_STATS: &str = "/admin/stats";
98
99/// The concrete cancel path for one task id.
100pub fn admin_task_cancel(task_id: &str) -> String {
101    ADMIN_TASK_CANCEL.replace("{task_id}", task_id)
102}
103
104/// The concrete resume path for one request id.
105pub fn v1_stream(request_id: &str) -> String {
106    V1_STREAM.replace("{request_id}", request_id)
107}
108
109/// The concrete polling-fallback path for one request id.
110pub fn v1_stream_poll(request_id: &str) -> String {
111    V1_STREAM_POLL.replace("{request_id}", request_id)
112}
113
114/// Every fixed route above, for clients that want to enumerate the
115/// surface (and for the round-trip test below).
116///
117/// [`ADMIN_TASK_CANCEL`], [`V1_STREAM`] and [`V1_STREAM_POLL`] are
118/// deliberately absent: they are templates, and a caller iterating this
119/// list to probe paths would get a 404 for a literal `{task_id}`.
120pub const ALL: &[&str] = &[
121    HEALTH,
122    METRICS,
123    CACHE_STATS,
124    V1_MODELS,
125    V1_CHAT_COMPLETIONS,
126    V1_COMPLETIONS,
127    V1_TOKENIZE,
128    V1_DETOKENIZE,
129    V1_EMBEDDINGS,
130    V1_MESSAGES,
131    V1_CANCEL,
132    ADMIN_MODELS,
133    ADMIN_MODELS_LOAD,
134    ADMIN_MODELS_UNLOAD,
135    ADMIN_DOWNLOAD,
136    ADMIN_TASKS,
137    ADMIN_STATS,
138];
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn every_route_is_absolute_and_unique() {
146        let mut seen = std::collections::BTreeSet::new();
147        for route in ALL {
148            assert!(route.starts_with('/'), "{route} is not an absolute path");
149            assert!(!route.ends_with('/'), "{route} has a trailing slash");
150            assert!(seen.insert(*route), "{route} is listed twice");
151        }
152    }
153
154    #[test]
155    fn the_admin_surface_is_namespaced() {
156        for route in ALL.iter().filter(|r| r.starts_with("/admin")) {
157            assert!(
158                route.starts_with("/admin/"),
159                "{route} would collide with the /admin prefix itself"
160            );
161        }
162    }
163
164    #[test]
165    fn the_cancel_template_is_not_enumerated_as_a_real_path() {
166        assert!(!ALL.contains(&ADMIN_TASK_CANCEL));
167        assert!(ADMIN_TASK_CANCEL.contains("{task_id}"));
168    }
169
170    /// Same rule for the stream templates: a client that probed this
171    /// list would ask for a literal `{request_id}` and get a 404 with
172    /// the contract crate's blessing.
173    #[test]
174    fn the_stream_templates_are_not_enumerated_as_real_paths() {
175        for template in [V1_STREAM, V1_STREAM_POLL] {
176            assert!(!ALL.contains(&template));
177            assert!(template.contains("{request_id}"));
178        }
179        assert_eq!(v1_stream("chatcmpl-7"), "/v1/stream/chatcmpl-7");
180        assert_eq!(
181            v1_stream_poll("chatcmpl-7"),
182            "/v1/stream/chatcmpl-7/poll".to_string()
183        );
184        assert!(!v1_stream("chatcmpl-7").contains('{'));
185    }
186
187    /// The polling fallback must sit under the stream it falls back
188    /// from, so one base URL and one key reach both.
189    #[test]
190    fn the_poll_route_is_nested_under_the_resume_route() {
191        assert!(V1_STREAM_POLL.starts_with(V1_STREAM));
192        assert!(V1_STREAM.starts_with("/v1/"));
193    }
194
195    #[test]
196    fn a_cancel_path_substitutes_the_only_placeholder() {
197        assert_eq!(
198            admin_task_cancel("task-7"),
199            "/admin/tasks/task-7/cancel".to_string()
200        );
201        assert!(!admin_task_cancel("task-7").contains('{'));
202    }
203}