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