frink-server 0.45.0

OpenAI-compatible HTTP server for the Frink inference engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Request fields that CHANGE WHAT COMES BACK and that this server does
//! not implement, refused BY NAME on every route that could carry them.
//!
//! Serde drops an undeclared field silently, and a caller cannot tell
//! that apart from having had it honoured: they get a 200 and an answer
//! computed under different rules than they asked for. That is the same
//! argument [`crate::unsupported_sampling`] makes for `logit_bias` and
//! `samplers`; this module is the rest of the surface, measured rather
//! than guessed.
//!
//! # What the measurement found
//!
//! Every field below was sent to a running server on 2026-09-22 and
//! answered **200** while appearing NOWHERE in `crates/frink-server`
//! or `crates/frink-api` (`grep -c` over both trees: zero). Two of them
//! were worse than absent -- they were refused on ONE route and dropped
//! on another:
//!
//! ```text
//! POST /v1/chat/completions {"n": 3}        -> 501  "n > 1 is not implemented"
//! POST /v1/completions      {"n": 3}        -> 200  one choice, no mention
//! ```
//!
//! which is this repo's dominant bug shape (two structures that must
//! agree about one thing, with nothing enforcing it) and is the exact
//! defect `sampling_knobs::ExtraSamplerFields` was built to close for
//! the knobs that ARE implemented. `n` was hand-written into the chat
//! route's validator and never reached the other two.
//!
//! # The line this table draws
//!
//! Only fields that change the TOKENS or the TEXT returned. A field
//! that a server may ignore without changing its answer -- a scheduling
//! hint, a tracing tag -- is not refused, because refusing it would
//! break a caller for whom ignoring it was correct.
//!
//! So `priority` is deliberately absent, and `cache_salt` is absent
//! with a reason worth writing down: it changes which prefix-cache
//! entries a request may reuse, so a server that IGNORES it can serve
//! one caller from another's cached prefix. frink's radix cache is
//! keyed by token ids and shared across requests
//! (`policy::radix`), so the field is not merely unimplemented here,
//! it names an isolation property this server does not yet offer. That
//! is a row of its own rather than a line in this table.
//!
//! # Why a flattened struct and not a `Value` walk
//!
//! The three generation routes take genuinely different bodies and
//! cannot share a request struct. They can share this one, flattened
//! into each, exactly as `ExtraSamplerFields` is -- so a field added
//! here reaches all three at once and none of them can be given a
//! refusal the others lack. [`UnimplementedFields::refuse`]
//! destructures exhaustively with no `..`, so a field added to the wire
//! struct and not answered is an unused variable and
//! `cargo clippy -- -D warnings` is a gate.

use serde_json::Value;

use crate::{unsupported_feature, ApiError};

/// The routes whose response shape can carry more than one answer.
///
/// STREAMING is a separate question and is refused where it applies:
/// `choices[].index` interleaving needs a steppable sampler, so a
/// streaming request with `n` > 1 is refused at the route that streams
/// rather than here (see `chat_completions_stream`).
///
/// Not "every OpenAI route": llama.cpp's native `/completion` returns a
/// single `content` string, and neither the Anthropic nor the Responses
/// wire has a `choices` array, so `n` has nowhere to go on them and is
/// refused by name rather than silently collapsed to one.
const SERVES_SEVERAL_CHOICES: &[&str] = &[
    frink_api::routes::V1_COMPLETIONS,
    frink_api::routes::V1_CHAT_COMPLETIONS,
];

/// Fields deserialized purely in order to be refused.
///
/// Every member is `Option`, and `None` is "the caller said nothing",
/// which is the only reading that leaves an existing client working.
/// Where a field has a default that matches what frink already does
/// (`n: 1`, `echo: false`, `skip_special_tokens: true`), that value is
/// accepted and only the other values are refused -- a caller who spells
/// out the default asked for the behaviour they are getting.
#[derive(Debug, Default, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub(crate) struct UnimplementedFields {
    /// OpenAI's `n`: how many completions to return. Refused above 1.
    pub(crate) n: Option<u32>,
    /// OpenAI's `best_of`: generate `k` and return the best-scoring
    /// one. Refused above 1. Was dropped on ALL THREE routes.
    pub(crate) best_of: Option<u32>,
    /// Per-token logprobs for the PROMPT, not the completion. frink
    /// scores prompt tokens in `frink perplexity` but exposes nothing
    /// for them over HTTP.
    pub(crate) prompt_logprobs: Option<Value>,
    /// Prepend the prompt to the returned text. SERVED on
    /// `/v1/completions`, which is the wire that has it.
    pub(crate) echo: Option<bool>,
    /// Beam search instead of the sampler chain.
    pub(crate) use_beam_search: Option<bool>,
    /// Drop the prompt to its last `k` tokens. SERVED
    /// (`GenerationParams::truncate_prompt_tokens`); it was the most
    /// dangerous member of this table while it was refused, because
    /// IGNORING it answers a different prompt than the caller believes
    /// they sent, with no error.
    pub(crate) truncate_prompt_tokens: Option<i64>,
    /// Pre-computed embeddings in place of text. A different input
    /// path entirely, not a knob on this one.
    pub(crate) prompt_embeds: Option<Value>,
    /// Restrict sampling to these ids.
    ///
    /// SERVED (`crate::token_mask`), like `cache_salt` below: named
    /// here because this table is where every route reads its shared
    /// fields from, and typed rather than `Value` because a served
    /// field has a shape.
    pub(crate) allowed_token_ids: Option<Vec<u32>>,
    /// Forbid these strings. `stop` is implemented and is not this:
    /// `stop` ENDS the generation, this one steers around a token.
    /// SERVED; see above.
    pub(crate) bad_words: Option<Vec<String>>,
    /// vLLM's `cache_salt`: the caller's prefix-cache namespace.
    ///
    /// Deserialized here because the table is where every route reads
    /// its shared fields from, but it is SERVED rather than refused --
    /// `crate::cache_salt` hashes it and the contiguous prefix cache
    /// and the response cache both key on it. The one place it is
    /// refused is a PAGED request, and that refusal lives at the
    /// route, because the store is a property of the deployment rather
    /// than of the field.
    pub(crate) cache_salt: Option<String>,
    /// Include special tokens in the returned text. frink always skips
    /// them, so `true` is accepted and `false` is refused.
    pub(crate) skip_special_tokens: Option<bool>,
    /// Return `"token_id:123"` strings in place of text pieces.
    pub(crate) return_tokens_as_token_ids: Option<bool>,
}

impl UnimplementedFields {
    /// How many completions to GENERATE: `best_of` when it is bigger
    /// than `n`, else `n`, else one. Upstream's default for `best_of`
    /// is `n`, so a request naming neither generates one.
    pub(crate) fn candidates(&self) -> usize {
        let n = self.n.unwrap_or(1).max(1) as usize;
        let k = self.best_of.unwrap_or(0) as usize;
        n.max(k)
    }

    /// How many of the prompt's last tokens to keep, if the caller
    /// asked. Validated by [`Self::refuse`] before this is read.
    pub(crate) fn truncate_prompt_tokens(&self) -> Option<usize> {
        self.truncate_prompt_tokens
            .filter(|&k| k >= 1)
            .map(|k| k as usize)
    }

    /// The two steering fields as one mask, with the bad words still
    /// unresolved: the route has no tokenizer.
    pub(crate) fn token_mask(&self) -> crate::token_mask::TokenMask {
        crate::token_mask::TokenMask::requested(
            self.allowed_token_ids
                .as_ref()
                .map(|ids| ids.iter().map(|&i| i as usize).collect()),
            self.bad_words.clone().unwrap_or_default(),
        )
    }

    /// True when more will be generated than returned, which is the
    /// only case that needs a score.
    pub(crate) fn ranks_candidates(&self) -> bool {
        self.candidates() > self.n.unwrap_or(1).max(1) as usize
    }

    /// Refuse anything the caller asked for that this server does not
    /// do, naming the field and what to reach for instead.
    ///
    /// **Exhaustive destructure, no `..`.** A member added above and
    /// not answered here fails the build.
    pub(crate) fn refuse(&self, route: &str) -> Result<(), ApiError> {
        let UnimplementedFields {
            n,
            best_of,
            prompt_logprobs,
            echo,
            use_beam_search,
            truncate_prompt_tokens,
            prompt_embeds,
            allowed_token_ids,
            bad_words,
            cache_salt,
            skip_special_tokens,
            return_tokens_as_token_ids,
        } = self;

        // `n` > 1 is SERVED on the routes whose response has a
        // `choices[]` array to put the extra answers in, and refused on
        // the ones that do not: llama.cpp's native `/completion`
        // returns one `content`, and the Anthropic and Responses wires
        // have no such field at all. `n: 1` is every route.
        if n.is_some_and(|v| v > 1) && !SERVES_SEVERAL_CHOICES.contains(&route) {
            return Err(refusal(
                route,
                "n",
                "more than one completion per request on this wire, which has no `choices` array \
                 to return them in; use /v1/completions or send the request again",
            ));
        }
        // `best_of` goes where `n` goes: it needs a `choices` array to
        // return the winners in, and the same fork to generate the
        // candidates. Scored by summed logprob (`crate::best_of`).
        if best_of.is_some_and(|v| v > 1) && !SERVES_SEVERAL_CHOICES.contains(&route) {
            return Err(refusal(
                route,
                "best_of",
                "generating several completions and returning the best-scoring one on this wire, \
                 which has no `choices` array to return them in",
            ));
        }
        // Upstream's own constraint, and it is a 400 rather than a 501
        // because the field IS implemented: asking for the best 3 of 2
        // is not a request any server can serve.
        if let (Some(k), Some(n)) = (best_of, n) {
            if k < n {
                return Err(crate::invalid_request(
                    &format!("`best_of` is {k} and `n` is {n}; best_of must be at least n"),
                    "best_of",
                ));
            }
        }
        // Served on `/v1/completions` (`crate::logprobs::render_prompt`).
        // Elsewhere it is refused by name: the chat wire has no field
        // for it, and the native `/completion` returns one `content`.
        if prompt_logprobs.is_some() && route != frink_api::routes::V1_COMPLETIONS {
            return Err(refusal(
                route,
                "prompt_logprobs",
                "logprobs for the PROMPT's own tokens on this wire, which has no field for them",
            ));
        }
        // Served on `/v1/completions` and nowhere else: the chat wire
        // returns a message rather than a continuation of the prompt,
        // and llama.cpp's native `/completion` has no such field.
        if echo == &Some(true) && route != frink_api::routes::V1_COMPLETIONS {
            return Err(refusal(
                route,
                "echo",
                "prepending the prompt to the completion on this wire, which returns a message \
                 rather than a continuation of the prompt",
            ));
        }
        if use_beam_search == &Some(true) {
            return Err(refusal(
                route,
                "use_beam_search",
                "beam search; this server samples",
            ));
        }
        // Served. A 400 rather than a 501 for the values no server can
        // serve: zero asks for a prompt of nothing, and a negative
        // count is not a length. `-1` is upstream's "the model's
        // maximum", which this server does not spell that way because
        // its ceiling is a deployment setting rather than a property
        // of the checkpoint -- naming the number is the honest form.
        if truncate_prompt_tokens.is_some_and(|k| k < 1) {
            return Err(crate::invalid_request(
                "`truncate_prompt_tokens` must be at least 1: it is how many of the prompt's \
                 last tokens to keep",
                "truncate_prompt_tokens",
            ));
        }
        if prompt_embeds.is_some() {
            return Err(refusal(
                route,
                "prompt_embeds",
                "embeddings as input in place of text",
            ));
        }
        // Served, not refused (`crate::token_mask`). An EMPTY
        // `allowed_token_ids` is a 400 rather than a 501: it is a
        // request to draw from nothing, which no server can serve, and
        // honouring it would produce a row of `-inf` and a token that
        // is an artefact of argmax over negative infinity.
        if allowed_token_ids.as_ref().is_some_and(|ids| ids.is_empty()) {
            return Err(crate::invalid_request(
                "`allowed_token_ids` is empty, so there is no token this request could draw",
                "allowed_token_ids",
            ));
        }
        let _ = bad_words;
        // Served, not refused; see the field. Named here so the
        // exhaustive destructure stays exhaustive.
        let _ = cache_salt;
        if skip_special_tokens == &Some(false) {
            return Err(refusal(
                route,
                "skip_special_tokens",
                "returning special tokens in the text; this server always skips them",
            ));
        }
        if return_tokens_as_token_ids == &Some(true) {
            return Err(refusal(
                route,
                "return_tokens_as_token_ids",
                "returning token ids in place of text pieces; `/v1/tokenize` returns ids",
            ));
        }
        Ok(())
    }
}

/// One sentence, one shape, naming the field and the route.
///
/// A 501 rather than a 400: the request is well-formed and a server
/// that implemented the field would serve it, which is the distinction
/// `unsupported_sampling` already draws between a typo and a gap.
fn refusal(route: &str, field: &str, what: &str) -> ApiError {
    unsupported_feature(&format!(
        "`{field}` is not implemented on {route}: {what} (see docs/API.md)"
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(body: serde_json::Value) -> UnimplementedFields {
        serde_json::from_value(body).expect("the struct is all-optional")
    }

    /// The defaults a caller may legitimately spell out are SERVED, not
    /// refused: they describe what this server already does.
    #[test]
    fn spelling_out_the_defaults_is_not_a_refusal() {
        for body in [
            serde_json::json!({ "n": 1 }),
            serde_json::json!({ "best_of": 1 }),
            serde_json::json!({ "echo": false }),
            serde_json::json!({ "use_beam_search": false }),
            serde_json::json!({ "skip_special_tokens": true }),
            serde_json::json!({ "return_tokens_as_token_ids": false }),
            serde_json::json!({}),
        ] {
            assert!(
                parse(body.clone())
                    .refuse(frink_api::routes::COMPLETION)
                    .is_ok(),
                "{body} should be served"
            );
        }
    }

    /// Every member refuses, and the message names the field. Driven
    /// from a list so a member added to the struct and forgotten here
    /// is visible as a count.
    #[test]
    fn every_field_refuses_by_name() {
        let cases: [(&str, serde_json::Value); 7] = [
            ("n", serde_json::json!({ "n": 2 })),
            ("best_of", serde_json::json!({ "best_of": 2 })),
            (
                "prompt_logprobs",
                serde_json::json!({ "prompt_logprobs": 1 }),
            ),
            (
                "use_beam_search",
                serde_json::json!({ "use_beam_search": true }),
            ),
            (
                "prompt_embeds",
                serde_json::json!({ "prompt_embeds": "AA==" }),
            ),
            (
                "skip_special_tokens",
                serde_json::json!({ "skip_special_tokens": false }),
            ),
            (
                "return_tokens_as_token_ids",
                serde_json::json!({ "return_tokens_as_token_ids": true }),
            ),
        ];
        // Every field of the struct is accounted for: refused above,
        // or named here as SERVED. A member added to the struct and
        // forgotten in both places changes the count and fails, which
        // is the whole reason this assertion exists.
        const SERVED: [&str; 5] = [
            "cache_salt",
            "allowed_token_ids",
            "bad_words",
            "echo",
            "truncate_prompt_tokens",
        ];
        assert_eq!(
            cases.len() + SERVED.len(),
            serde_json::to_value(UnimplementedFields::default())
                .expect("serializes")
                .as_object()
                .expect("an object")
                .len(),
            "every field of the struct must be refused above or listed in SERVED"
        );
        for (field, body) in cases {
            // The native wire, which serves none of them: `n` is
            // SERVED on `/v1/completions` (see
            // `a_route_with_a_choices_array_serves_n`), and picking a
            // route that refuses everything keeps this test about the
            // table rather than about the exception.
            let err = parse(body)
                .refuse(frink_api::routes::COMPLETION)
                .expect_err("{field} must refuse");
            let msg = format!("{err:?}");
            assert!(msg.contains(field), "{field} not named in {msg}");
        }
    }

    /// `n` is the one field with a per-route answer, and both halves
    /// are pinned: served where the response has a `choices` array to
    /// put the answers in, refused by name where it does not.
    #[test]
    fn a_route_with_a_choices_array_serves_n() {
        let four = parse(serde_json::json!({ "n": 4 }));
        for route in [
            frink_api::routes::V1_COMPLETIONS,
            frink_api::routes::V1_CHAT_COMPLETIONS,
        ] {
            assert!(
                four.refuse(route).is_ok(),
                "{route} renders several choices and must serve `n`"
            );
        }
        // llama.cpp's native wire returns a single `content`, so there
        // is nowhere to put a second answer.
        let err = four
            .refuse(frink_api::routes::COMPLETION)
            .expect_err("no choices array");
        assert!(format!("{err:?}").contains('n'));
        // And `n: 1` is every route, including the ones that refuse
        // more: a caller spelling out the default asked for what they
        // are getting.
        let one = parse(serde_json::json!({ "n": 1 }));
        for route in [
            frink_api::routes::COMPLETION,
            frink_api::routes::V1_CHAT_COMPLETIONS,
            frink_api::routes::V1_COMPLETIONS,
        ] {
            assert!(one.refuse(route).is_ok(), "{route} refused n = 1");
        }
    }

    /// The route is in the message, because the same field may be
    /// implemented on one wire and not another later and a caller
    /// reading the error should not have to guess which one refused.
    #[test]
    fn the_message_names_the_route() {
        let err = parse(serde_json::json!({ "echo": true }))
            .refuse("/v1/chat/completions")
            .expect_err("refuses");
        assert!(format!("{err:?}").contains("/v1/chat/completions"));
    }
}