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/// Anthropic's prompt-sizing endpoint: how many input tokens a request
29/// *would* cost, without generating any. Behind the same key as
30/// [`V1_MESSAGES`], because answering it requires the loaded
31/// checkpoint's own tokenizer and chat template.
32pub const V1_MESSAGES_COUNT_TOKENS: &str = "/v1/messages/count_tokens";
33
34/// Explicit cancellation of one in-flight generation, by the
35/// `request_id` the server states on the first streamed chunk.
36///
37/// Under `/v1` rather than at the root the plan sketched it at, for two
38/// reasons that both matter: it acts on inference and so belongs behind
39/// the same `FERROX_API_KEY` gate as the endpoint that started the
40/// work, and `/v1` is where every other inference path already lives,
41/// so a client configured with one base URL reaches all of them.
42///
43/// This is the second tier of cancellation, not the only one -- a
44/// client that simply drops the connection is also honoured. It exists
45/// because the first tier is unreliable: proxies buffer, and a page
46/// unload races the abort it is supposed to send. `keepalive: true`
47/// makes this one survive that.
48pub const V1_CANCEL: &str = "/v1/cancel";
49
50/// Reconnect into a stream started with `stream_resumable: true`,
51/// resuming after the `Last-Event-ID` the client last saw.
52///
53/// **A template, not a literal** -- see [`ADMIN_TASK_CANCEL`] for why
54/// this crate writes placeholders in the OpenAPI style. Build a
55/// concrete path with [`v1_stream`].
56///
57/// Behind the same key as the endpoint that started the work: the
58/// replay buffer holds the model's output, so reading it must cost
59/// exactly what producing it cost.
60pub const V1_STREAM: &str = "/v1/stream/{request_id}";
61
62/// The same replay buffer over plain JSON, for the case SSE cannot
63/// survive: a reverse proxy that buffers `text/event-stream` turns a
64/// stream into one long silence, and cannot do that to a short response
65/// that has already ended. Build a concrete path with
66/// [`v1_stream_poll`].
67pub const V1_STREAM_POLL: &str = "/v1/stream/{request_id}/poll";
68
69// ---------------------------------------------------------------------
70// Control surface.
71//
72// Everything under `/admin` either changes what the server serves or
73// writes to disk, so all of it sits behind the same `FERROX_API_KEY`
74// gate as `/v1/*` -- never on the unauthenticated `/health` side.
75// ---------------------------------------------------------------------
76
77/// Model inventory: what is on disk, what is loaded, what failed.
78pub const ADMIN_MODELS: &str = "/admin/models";
79
80/// Start loading a discovered model by its `id`. Answers `202` with a
81/// task id; the load itself runs off the request.
82pub const ADMIN_MODELS_LOAD: &str = "/admin/models/load";
83
84/// Drop the active model. Synchronous: unloading is releasing one
85/// `Arc`, and requests already decoding keep theirs.
86pub const ADMIN_MODELS_UNLOAD: &str = "/admin/models/unload";
87
88/// Fetch a `.gguf` from the Hugging Face Hub into the model directory.
89/// Answers `202` with a task id.
90pub const ADMIN_DOWNLOAD: &str = "/admin/download";
91
92/// Every long-running job this server knows about, newest first.
93pub const ADMIN_TASKS: &str = "/admin/tasks";
94
95/// Request cancellation of one task. **A template, not a literal**: the
96/// `{task_id}` placeholder is written in the OpenAPI style rather than
97/// any one web framework's, because this crate is imported by clients
98/// that have never heard of the server's router. Build a concrete path
99/// with [`admin_task_cancel`].
100pub const ADMIN_TASK_CANCEL: &str = "/admin/tasks/{task_id}/cancel";
101
102/// Counters, uptime, and the recent-request ring buffer.
103pub const ADMIN_STATS: &str = "/admin/stats";
104
105/// The OpenAI **Responses** surface -- what `codex` speaks. A different
106/// request/response shaping over the same generation path, not a second
107/// engine.
108pub const V1_RESPONSES: &str = "/v1/responses";
109
110/// One stored response. This server is stateless, so it answers 404 --
111/// deliberately, rather than 404-ing from the router, because the two
112/// say different things: the route EXISTS and keeps nothing, which
113/// tells a client to stop polling rather than to check its base URL.
114pub const V1_RESPONSE: &str = "/v1/responses/{response_id}";
115
116/// Cancel one stored response. Same stateless answer; a live generation
117/// is stopped through [`V1_CANCEL`] with its `request_id`.
118pub const V1_RESPONSE_CANCEL: &str = "/v1/responses/{response_id}/cancel";
119
120/// Live serving telemetry: throughput over a trailing window, request
121/// latency percentile, and the cache pools' occupancy. Distinct from
122/// [`ADMIN_STATS`], which is this server's own operational ring; this
123/// is the shape a desktop or dashboard polls.
124pub const V1_STATS: &str = "/v1/stats";
125
126/// Incremental page over the recent-request ring: `?since=<cursor>` and
127/// `?limit=<n>`. The cursor is all-time, so a poller that keeps up
128/// reads each row exactly once.
129pub const V1_REQUESTS: &str = "/v1/requests";
130
131/// The current cache geometry: how VRAM is split between the expert
132/// cache and the KV pools, and what a re-split could move.
133pub const V1_CACHE_STATUS: &str = "/v1/cache/status";
134
135/// Re-split the caches on a live engine. New generation is refused
136/// while a rebuild is in flight.
137pub const V1_CACHE_REBUILD: &str = "/v1/cache/rebuild";
138
139/// Close admission, drain, and seal the final accounting snapshot. A
140/// supervisor calls this before it sends a signal, so process shutdown
141/// cannot race the last sampled token.
142pub const ADMIN_PREPARE_STOP: &str = "/v1/admin/prepare-stop";
143
144/// The concrete cancel path for one task id.
145pub fn admin_task_cancel(task_id: &str) -> String {
146 ADMIN_TASK_CANCEL.replace("{task_id}", task_id)
147}
148
149/// The concrete resume path for one request id.
150pub fn v1_stream(request_id: &str) -> String {
151 V1_STREAM.replace("{request_id}", request_id)
152}
153
154/// The concrete polling-fallback path for one request id.
155pub fn v1_stream_poll(request_id: &str) -> String {
156 V1_STREAM_POLL.replace("{request_id}", request_id)
157}
158
159/// Every fixed route above, for clients that want to enumerate the
160/// surface (and for the round-trip test below).
161///
162/// [`ADMIN_TASK_CANCEL`], [`V1_STREAM`] and [`V1_STREAM_POLL`] are
163/// deliberately absent: they are templates, and a caller iterating this
164/// list to probe paths would get a 404 for a literal `{task_id}`.
165pub const ALL: &[&str] = &[
166 HEALTH,
167 METRICS,
168 CACHE_STATS,
169 V1_MODELS,
170 V1_CHAT_COMPLETIONS,
171 V1_COMPLETIONS,
172 V1_TOKENIZE,
173 V1_DETOKENIZE,
174 V1_EMBEDDINGS,
175 V1_MESSAGES,
176 V1_MESSAGES_COUNT_TOKENS,
177 V1_CANCEL,
178 ADMIN_MODELS,
179 ADMIN_MODELS_LOAD,
180 ADMIN_MODELS_UNLOAD,
181 ADMIN_DOWNLOAD,
182 ADMIN_TASKS,
183 ADMIN_STATS,
184 V1_RESPONSES,
185 V1_STATS,
186 V1_REQUESTS,
187 V1_CACHE_STATUS,
188 V1_CACHE_REBUILD,
189 ADMIN_PREPARE_STOP,
190];
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn every_route_is_absolute_and_unique() {
198 let mut seen = std::collections::BTreeSet::new();
199 for route in ALL {
200 assert!(route.starts_with('/'), "{route} is not an absolute path");
201 assert!(!route.ends_with('/'), "{route} has a trailing slash");
202 assert!(seen.insert(*route), "{route} is listed twice");
203 }
204 }
205
206 #[test]
207 fn the_admin_surface_is_namespaced() {
208 for route in ALL.iter().filter(|r| r.starts_with("/admin")) {
209 assert!(
210 route.starts_with("/admin/"),
211 "{route} would collide with the /admin prefix itself"
212 );
213 }
214 }
215
216 #[test]
217 fn the_cancel_template_is_not_enumerated_as_a_real_path() {
218 assert!(!ALL.contains(&ADMIN_TASK_CANCEL));
219 assert!(ADMIN_TASK_CANCEL.contains("{task_id}"));
220 }
221
222 /// Same rule for the stream templates: a client that probed this
223 /// list would ask for a literal `{request_id}` and get a 404 with
224 /// the contract crate's blessing.
225 #[test]
226 fn the_stream_templates_are_not_enumerated_as_real_paths() {
227 for template in [V1_STREAM, V1_STREAM_POLL] {
228 assert!(!ALL.contains(&template));
229 assert!(template.contains("{request_id}"));
230 }
231 assert_eq!(v1_stream("chatcmpl-7"), "/v1/stream/chatcmpl-7");
232 assert_eq!(
233 v1_stream_poll("chatcmpl-7"),
234 "/v1/stream/chatcmpl-7/poll".to_string()
235 );
236 assert!(!v1_stream("chatcmpl-7").contains('{'));
237 }
238
239 /// The polling fallback must sit under the stream it falls back
240 /// from, so one base URL and one key reach both.
241 #[test]
242 fn the_poll_route_is_nested_under_the_resume_route() {
243 assert!(V1_STREAM_POLL.starts_with(V1_STREAM));
244 assert!(V1_STREAM.starts_with("/v1/"));
245 }
246
247 #[test]
248 fn a_cancel_path_substitutes_the_only_placeholder() {
249 assert_eq!(
250 admin_task_cancel("task-7"),
251 "/admin/tasks/task-7/cancel".to_string()
252 );
253 assert!(!admin_task_cancel("task-7").contains('{'));
254 }
255}