frink_api/routes.rs
1//! Every path `frink-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 `FRINK_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 frink'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 frink'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` (frink's).
56pub const DETOKENIZE: &str = "/detokenize";
57pub const V1_EMBEDDINGS: &str = "/v1/embeddings";
58
59/// Cross-encoder reranking: one query against N documents, scored by
60/// the checkpoint's own classification head (`cls` / `cls.output`), not
61/// by the cosine similarity of two embeddings.
62///
63/// The path Cohere and Jina clients use, and one of the four llama.cpp
64/// mounts. Not an OpenAI endpoint at all -- OpenAI has no reranker --
65/// so the request and response shapes follow Jina/Cohere, which is what
66/// every existing client already speaks.
67/// Sentence-pair scoring: how related are these two texts?
68///
69/// The endpoint `/v1/rerank` is built on top of, exposed on its own.
70/// It differs in admitting BOTH kinds of encoder -- a cross-encoder
71/// answers with its head, a bi-encoder with the cosine of two
72/// embeddings -- and saying which one did.
73pub const V1_SCORE: &str = "/v1/score";
74
75/// The unprefixed spelling, on the same handler.
76pub const SCORE: &str = "/score";
77
78pub const V1_RERANK: &str = "/v1/rerank";
79
80/// llama.cpp's unprefixed spelling of [`V1_RERANK`], mounted on the
81/// same handler (`tools/server/server.cpp` registers `/rerank`,
82/// `/reranking`, `/v1/rerank` and `/v1/reranking`).
83///
84/// Same reasoning as [`TOKENIZE`]: the client's configured URL should
85/// work unchanged rather than 404 on a prefix frink chose. Unlike
86/// [`COMPLETION`], this one really is an alias -- same dialect, same
87/// body, same response.
88pub const RERANK: &str = "/rerank";
89
90/// Anthropic-compatible messages endpoint.
91pub const V1_MESSAGES: &str = "/v1/messages";
92
93/// Anthropic's prompt-sizing endpoint: how many input tokens a request
94/// *would* cost, without generating any. Behind the same key as
95/// [`V1_MESSAGES`], because answering it requires the loaded
96/// checkpoint's own tokenizer and chat template.
97pub const V1_MESSAGES_COUNT_TOKENS: &str = "/v1/messages/count_tokens";
98
99/// Explicit cancellation of one in-flight generation, by the
100/// `request_id` the server states on the first streamed chunk.
101///
102/// Under `/v1` rather than at the root the plan sketched it at, for two
103/// reasons that both matter: it acts on inference and so belongs behind
104/// the same `FRINK_API_KEY` gate as the endpoint that started the
105/// work, and `/v1` is where every other inference path already lives,
106/// so a client configured with one base URL reaches all of them.
107///
108/// This is the second tier of cancellation, not the only one -- a
109/// client that simply drops the connection is also honoured. It exists
110/// because the first tier is unreliable: proxies buffer, and a page
111/// unload races the abort it is supposed to send. `keepalive: true`
112/// makes this one survive that.
113pub const V1_CANCEL: &str = "/v1/cancel";
114
115/// Reconnect into a stream started with `stream_resumable: true`,
116/// resuming after the `Last-Event-ID` the client last saw.
117///
118/// **A template, not a literal** -- see [`ADMIN_TASK_CANCEL`] for why
119/// this crate writes placeholders in the OpenAPI style. Build a
120/// concrete path with [`v1_stream`].
121///
122/// Behind the same key as the endpoint that started the work: the
123/// replay buffer holds the model's output, so reading it must cost
124/// exactly what producing it cost.
125pub const V1_STREAM: &str = "/v1/stream/{request_id}";
126
127/// The same replay buffer over plain JSON, for the case SSE cannot
128/// survive: a reverse proxy that buffers `text/event-stream` turns a
129/// stream into one long silence, and cannot do that to a short response
130/// that has already ended. Build a concrete path with
131/// [`v1_stream_poll`].
132pub const V1_STREAM_POLL: &str = "/v1/stream/{request_id}/poll";
133
134// ---------------------------------------------------------------------
135// Control surface.
136//
137// Everything under `/admin` either changes what the server serves or
138// writes to disk, so all of it sits behind the same `FRINK_API_KEY`
139// gate as `/v1/*` -- never on the unauthenticated `/health` side.
140// ---------------------------------------------------------------------
141
142/// Model inventory: what is on disk, what is loaded, what failed.
143pub const ADMIN_MODELS: &str = "/admin/models";
144
145/// Start loading a discovered model by its `id`. Answers `202` with a
146/// task id; the load itself runs off the request.
147pub const ADMIN_MODELS_LOAD: &str = "/admin/models/load";
148
149/// Drop the active model. Synchronous: unloading is releasing one
150/// `Arc`, and requests already decoding keep theirs.
151pub const ADMIN_MODELS_UNLOAD: &str = "/admin/models/unload";
152
153/// Free the active model's memory WITHOUT forgetting which model it
154/// was, so `WAKE_UP` can put it back.
155///
156/// The difference from `ADMIN_MODELS_UNLOAD` is the remembering: an
157/// unload leaves the server with nothing to serve and no idea what it
158/// used to serve, and a client has to know the id to bring it back.
159/// A sleep is reversible by the server itself, which is what makes it
160/// usable from a scheduler that does not know the deployment.
161pub const SLEEP: &str = "/sleep";
162
163/// Reload the model a `SLEEP` put away.
164pub const WAKE_UP: &str = "/wake_up";
165
166/// Whether this server is asleep.
167pub const IS_SLEEPING: &str = "/is_sleeping";
168
169/// Fetch a `.gguf` from the Hugging Face Hub into the model directory.
170/// Answers `202` with a task id.
171pub const ADMIN_DOWNLOAD: &str = "/admin/download";
172
173/// Every long-running job this server knows about, newest first.
174pub const ADMIN_TASKS: &str = "/admin/tasks";
175
176/// Request cancellation of one task. **A template, not a literal**: the
177/// `{task_id}` placeholder is written in the OpenAPI style rather than
178/// any one web framework's, because this crate is imported by clients
179/// that have never heard of the server's router. Build a concrete path
180/// with [`admin_task_cancel`].
181pub const ADMIN_TASK_CANCEL: &str = "/admin/tasks/{task_id}/cancel";
182
183/// Counters, uptime, and the recent-request ring buffer.
184pub const ADMIN_STATS: &str = "/admin/stats";
185
186/// The OpenAI **Responses** surface -- what `codex` speaks. A different
187/// request/response shaping over the same generation path, not a second
188/// engine.
189pub const V1_RESPONSES: &str = "/v1/responses";
190
191/// One stored response. This server is stateless, so it answers 404 --
192/// deliberately, rather than 404-ing from the router, because the two
193/// say different things: the route EXISTS and keeps nothing, which
194/// tells a client to stop polling rather than to check its base URL.
195pub const V1_RESPONSE: &str = "/v1/responses/{response_id}";
196
197/// Cancel one stored response. Same stateless answer; a live generation
198/// is stopped through [`V1_CANCEL`] with its `request_id`.
199pub const V1_RESPONSE_CANCEL: &str = "/v1/responses/{response_id}/cancel";
200
201/// Live serving telemetry: throughput over a trailing window, request
202/// latency percentile, and the cache pools' occupancy. Distinct from
203/// [`ADMIN_STATS`], which is this server's own operational ring; this
204/// is the shape a desktop or dashboard polls.
205pub const V1_STATS: &str = "/v1/stats";
206
207/// Incremental page over the recent-request ring: `?since=<cursor>` and
208/// `?limit=<n>`. The cursor is all-time, so a poller that keeps up
209/// reads each row exactly once.
210pub const V1_REQUESTS: &str = "/v1/requests";
211
212/// The current cache geometry: how VRAM is split between the expert
213/// cache and the KV pools, and what a re-split could move.
214pub const V1_CACHE_STATUS: &str = "/v1/cache/status";
215
216/// Re-split the caches on a live engine. New generation is refused
217/// while a rebuild is in flight.
218pub const V1_CACHE_REBUILD: &str = "/v1/cache/rebuild";
219
220/// Close admission, drain, and seal the final accounting snapshot. A
221/// supervisor calls this before it sends a signal, so process shutdown
222/// cannot race the last sampled token.
223pub const ADMIN_PREPARE_STOP: &str = "/v1/admin/prepare-stop";
224
225/// The loaded LoRA adapters, llama.cpp's `GET /lora-adapters` (the
226/// list with each adapter's current scale) and `POST /lora-adapters`
227/// (set the scales; an adapter not named goes to 0). See
228/// [`crate::lora`].
229pub const LORA_ADAPTERS: &str = "/lora-adapters";
230
231/// Save or restore one slot's KV state, llama.cpp's
232/// `POST /slots/:id_slot?action=save|restore`.
233///
234/// A template like [`V1_STREAM`], and absent from [`ALL`] for the same
235/// reason: a caller probing a literal `{id_slot}` would get a 404.
236pub const SLOTS_ID: &str = "/slots/{id_slot}";
237
238/// The concrete slot path for one slot id.
239pub fn slots_id(id_slot: u32) -> String {
240 SLOTS_ID.replace("{id_slot}", &id_slot.to_string())
241}
242
243/// The concrete cancel path for one task id.
244pub fn admin_task_cancel(task_id: &str) -> String {
245 ADMIN_TASK_CANCEL.replace("{task_id}", task_id)
246}
247
248/// The concrete resume path for one request id.
249pub fn v1_stream(request_id: &str) -> String {
250 V1_STREAM.replace("{request_id}", request_id)
251}
252
253/// The concrete polling-fallback path for one request id.
254pub fn v1_stream_poll(request_id: &str) -> String {
255 V1_STREAM_POLL.replace("{request_id}", request_id)
256}
257
258/// Every fixed route above, for clients that want to enumerate the
259/// surface (and for the round-trip test below).
260///
261/// [`ADMIN_TASK_CANCEL`], [`V1_STREAM`], [`V1_STREAM_POLL`] and
262/// [`SLOTS_ID`] are deliberately absent: they are templates, and a
263/// caller iterating this list to probe paths would get a 404 for a
264/// literal `{task_id}`.
265pub const ALL: &[&str] = &[
266 HEALTH,
267 METRICS,
268 CACHE_STATS,
269 V1_MODELS,
270 V1_CHAT_COMPLETIONS,
271 V1_COMPLETIONS,
272 COMPLETION,
273 COMPLETIONS,
274 V1_TOKENIZE,
275 V1_DETOKENIZE,
276 TOKENIZE,
277 DETOKENIZE,
278 V1_EMBEDDINGS,
279 V1_SCORE,
280 SCORE,
281 V1_RERANK,
282 RERANK,
283 V1_MESSAGES,
284 V1_MESSAGES_COUNT_TOKENS,
285 V1_CANCEL,
286 ADMIN_MODELS,
287 ADMIN_MODELS_LOAD,
288 ADMIN_MODELS_UNLOAD,
289 ADMIN_DOWNLOAD,
290 ADMIN_TASKS,
291 ADMIN_STATS,
292 V1_RESPONSES,
293 V1_STATS,
294 V1_REQUESTS,
295 V1_CACHE_STATUS,
296 V1_CACHE_REBUILD,
297 ADMIN_PREPARE_STOP,
298 LORA_ADAPTERS,
299];
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn every_route_is_absolute_and_unique() {
307 let mut seen = std::collections::BTreeSet::new();
308 for route in ALL {
309 assert!(route.starts_with('/'), "{route} is not an absolute path");
310 assert!(!route.ends_with('/'), "{route} has a trailing slash");
311 assert!(seen.insert(*route), "{route} is listed twice");
312 }
313 }
314
315 /// The whole point of these two is that a llama.cpp client's URL
316 /// works unchanged, so each must be its `/v1` twin with the prefix
317 /// removed and nothing else changed.
318 #[test]
319 fn the_llama_cpp_aliases_are_the_v1_paths_without_the_prefix() {
320 for (alias, v1) in [
321 (TOKENIZE, V1_TOKENIZE),
322 (DETOKENIZE, V1_DETOKENIZE),
323 (RERANK, V1_RERANK),
324 ] {
325 assert_eq!(v1, format!("/v1{alias}"));
326 assert!(ALL.contains(&alias), "{alias} must be enumerable");
327 }
328 }
329
330 #[test]
331 fn the_admin_surface_is_namespaced() {
332 for route in ALL.iter().filter(|r| r.starts_with("/admin")) {
333 assert!(
334 route.starts_with("/admin/"),
335 "{route} would collide with the /admin prefix itself"
336 );
337 }
338 }
339
340 #[test]
341 fn the_cancel_template_is_not_enumerated_as_a_real_path() {
342 assert!(!ALL.contains(&ADMIN_TASK_CANCEL));
343 assert!(ADMIN_TASK_CANCEL.contains("{task_id}"));
344 }
345
346 /// Same rule for the stream templates: a client that probed this
347 /// list would ask for a literal `{request_id}` and get a 404 with
348 /// the contract crate's blessing.
349 #[test]
350 fn the_stream_templates_are_not_enumerated_as_real_paths() {
351 for template in [V1_STREAM, V1_STREAM_POLL] {
352 assert!(!ALL.contains(&template));
353 assert!(template.contains("{request_id}"));
354 }
355 assert_eq!(v1_stream("chatcmpl-7"), "/v1/stream/chatcmpl-7");
356 assert_eq!(
357 v1_stream_poll("chatcmpl-7"),
358 "/v1/stream/chatcmpl-7/poll".to_string()
359 );
360 assert!(!v1_stream("chatcmpl-7").contains('{'));
361 }
362
363 /// The polling fallback must sit under the stream it falls back
364 /// from, so one base URL and one key reach both.
365 #[test]
366 fn the_poll_route_is_nested_under_the_resume_route() {
367 assert!(V1_STREAM_POLL.starts_with(V1_STREAM));
368 assert!(V1_STREAM.starts_with("/v1/"));
369 }
370
371 #[test]
372 fn a_cancel_path_substitutes_the_only_placeholder() {
373 assert_eq!(
374 admin_task_cancel("task-7"),
375 "/admin/tasks/task-7/cancel".to_string()
376 );
377 assert!(!admin_task_cancel("task-7").contains('{'));
378 }
379}