Skip to main content

jev_bridge/
server.rs

1//! The bridge: one resident upstream model behind a Jev-style scoring API.
2
3use std::sync::Arc;
4
5use anyhow::{bail, Context, Result};
6use axum::{
7    body::Bytes,
8    extract::State,
9    http::{header::AUTHORIZATION, HeaderMap, StatusCode},
10    response::{IntoResponse, Response},
11    routing::{get, post},
12    Json, Router,
13};
14use serde::Serialize;
15use serde_json::{json, Value};
16use tokio::sync::Mutex;
17
18use crate::prompt::{
19    direct_messages, direct_messages_reusing, evidence_json, prompt_sha256, softmax, ChatTemplate,
20    LocalComponents, RuntimeClient, LETTERS, PROMPT_VERSION,
21};
22use crate::strategy::{
23    build_body_with_candidates, candidate_ladder, candidates_restricted_to, input_tokens,
24    parse_batch_candidates, parse_candidates, parse_logprobs, request_url,
25    slot_logprobs_from_candidates, truncated, CompletionResponse, Probe,
26};
27use crate::wire::{
28    request_batch, response_from_results, OrderedMap, Question, ReadoutStatus, Row, ScoredAnswer,
29    PROBABILITY_STATUS,
30};
31use std::sync::atomic::{AtomicU64, Ordering};
32
33/// One configured bridge over a single upstream model.
34pub struct Bridge {
35    client: reqwest::Client,
36    template: ChatTemplate,
37    probe: Probe,
38    upstream_model: String,
39    upstream_key: Option<String>,
40    openai_base: String,
41    native_base: String,
42    slots: Vec<u32>,
43    served_model: String,
44    description: String,
45    release_date: String,
46    max_input_tokens: Option<usize>,
47    /// Accept any non-empty `model` name instead of requiring the served model.
48    accept_any_model: bool,
49    /// What the deployment declared about the readout channel on this model.
50    readout: ReadoutStatus,
51    /// How many batched readouts were served by the sequential fallback
52    /// because the endpoint rejected the array-`prompt` shape. Non-zero means
53    /// the shared prefill did not happen; `GET /health` reports it.
54    batch_fallbacks: AtomicU64,
55    /// Serialize upstream work so one resident model is not driven concurrently.
56    lock: Mutex<()>,
57}
58
59/// One question's readout from one upstream call.
60struct Readout {
61    /// Raw option log probabilities, before the softmax.
62    logprobs: Vec<f64>,
63    /// Prompt tokens attributed to this question. For the tail of a batched
64    /// readout this is 0: the endpoint reports one number for the whole batch
65    /// and it is attached to the first readout, so summing the vector
66    /// reproduces the endpoint's own count instead of a faked per-prompt split.
67    input_tokens: u64,
68}
69
70/// A non-success HTTP response from the upstream service.
71///
72/// The status code is kept because callers have to tell a *request-shape*
73/// rejection — the endpoint refuses this shape at all, where a fallback can
74/// help — from a call-level failure. A 429, 401/403 or 5xx must propagate:
75/// falling back there would turn a throttled or broken endpoint into a
76/// "successful" run.
77#[derive(Debug)]
78pub struct UpstreamStatus {
79    /// The URL that was posted to.
80    pub url: String,
81    /// The HTTP status the upstream returned.
82    pub status: u16,
83    /// The first 400 characters of the response body.
84    pub body: String,
85}
86
87impl UpstreamStatus {
88    /// Whether the endpoint rejected the request *shape* rather than failing
89    /// on this particular call.
90    pub fn is_shape_rejection(&self) -> bool {
91        matches!(self.status, 400 | 404 | 405 | 415 | 422)
92    }
93}
94
95impl std::fmt::Display for UpstreamStatus {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "POST {} returned HTTP {}: {}", self.url, self.status, self.body)
98    }
99}
100
101impl std::error::Error for UpstreamStatus {}
102
103/// Everything [`Bridge::connect`] needs to reach one upstream model.
104pub struct BridgeConfig {
105    /// OpenAI-compatible root, for example `http://127.0.0.1:8080/v1`.
106    pub openai_base: String,
107    /// Native root for llama.cpp's `/completion`.
108    pub native_base: String,
109    /// Model name sent upstream.
110    pub upstream_model: String,
111    /// Bearer token for the upstream service, when it needs one.
112    pub upstream_key: Option<String>,
113    /// Model id this bridge accepts from clients.
114    pub served_model: String,
115    /// Description returned by `GET /v1/models`.
116    pub description: String,
117    /// ISO release date returned by `GET /v1/models`.
118    pub release_date: String,
119    /// Template variables, for example `{"enable_thinking": false}`.
120    pub chat_template_kwargs: Value,
121    /// Reject rows above this token count instead of truncating them.
122    pub max_input_tokens: Option<usize>,
123    /// Render the chat template and/or tokenize in-process instead of asking
124    /// the runtime.
125    pub local: LocalComponents,
126    /// Accept any non-empty `model` name in a request instead of requiring the
127    /// served model.
128    ///
129    /// Off by default: the bridge does not serve model ids it is not (that is
130    /// what the `jev` prefix check on `--served-model` protects). Third-party
131    /// TypeSafe clients hard-code an id like `jev-latest`, so a deployment
132    /// that wants to serve them turns this on explicitly. Responses still
133    /// report the served model.
134    pub accept_any_model: bool,
135    /// What the deployment knows about the readout channel on this model.
136    ///
137    /// Defaults to `unvalidated`: the bridge cannot prove readout quality at
138    /// runtime, so a deployment that checked it (for example with
139    /// [`crate::evaluate`] over an alignment run) states so explicitly.
140    pub readout: ReadoutStatus,
141}
142
143/// A scored row plus the evidence needed to compare a bridged run against a
144/// reference run row by row.
145#[derive(Debug, Clone)]
146pub struct DetailedScore {
147    /// The normalized answer handed to the wire layer.
148    pub answer: ScoredAnswer,
149    /// Raw option log probabilities, before the softmax.
150    pub option_logprobs: Vec<f64>,
151    /// SHA-256 of the rendered prompt, for comparing runs row by row.
152    pub prompt_sha256: String,
153}
154
155impl Bridge {
156    /// Resolve the prompt contract and pick a transport, or fail with reasons.
157    pub async fn connect(client: reqwest::Client, mut config: BridgeConfig) -> Result<Self> {
158        let runtime = RuntimeClient::new(
159            client.clone(),
160            config.native_base.clone(),
161            config.upstream_model.clone(),
162            config.upstream_key.clone(),
163        );
164        let template = ChatTemplate::new(
165            runtime,
166            std::mem::take(&mut config.local),
167            config.chat_template_kwargs.clone(),
168        );
169
170        let slots = template
171            .resolve_slots()
172            .await
173            .context("resolving the single-token answer slots failed")?;
174
175        // A sixteen-option probe exercises the widest contract this bridge
176        // serves, so a transport that passes it will hold for real requests.
177        let probe_options: Vec<crate::wire::RowOption> = LETTERS
178            .chars()
179            .map(|letter| crate::wire::RowOption {
180                id: letter.to_string(),
181                description: format!("Probe option {letter}"),
182            })
183            .collect();
184        let messages = direct_messages(&json!("probe"), "probe", &probe_options);
185        let prompt = template
186            .render(&messages)
187            .await
188            .context("rendering the probe prompt failed")?;
189        template
190            .verify_boundary(&prompt, &slots)
191            .await
192            .context("verifying the answer-token boundary failed")?;
193
194        let letters: Vec<char> = LETTERS.chars().collect();
195        let mut chosen = None;
196        let mut failures = Vec::new();
197        for probe in crate::strategy::CANDIDATES {
198            match try_probe(
199                &client,
200                probe,
201                &config,
202                &prompt,
203                &slots,
204                &letters,
205            )
206            .await
207            {
208                Ok(()) => {
209                    chosen = Some(probe);
210                    break;
211                }
212                Err(error) => failures.push(format!("{}: {error:#}", probe.name())),
213            }
214        }
215        let probe = chosen.ok_or_else(|| {
216            anyhow::anyhow!(
217                "no supported transport answered the probe:\n  {}",
218                failures.join("\n  ")
219            )
220        })?;
221
222        Ok(Self {
223            client,
224            template,
225            probe,
226            upstream_model: config.upstream_model,
227            upstream_key: config.upstream_key,
228            openai_base: config.openai_base,
229            native_base: config.native_base,
230            slots,
231            served_model: config.served_model,
232            description: config.description,
233            release_date: config.release_date,
234            max_input_tokens: config.max_input_tokens,
235            accept_any_model: config.accept_any_model,
236            readout: config.readout,
237            batch_fallbacks: AtomicU64::new(0),
238            lock: Mutex::new(()),
239        })
240    }
241
242    /// The transport negotiated at startup.
243    pub fn probe(&self) -> Probe {
244        self.probe
245    }
246
247    /// What the deployment declared about the readout channel on this model.
248    pub fn readout(&self) -> &ReadoutStatus {
249        &self.readout
250    }
251
252    /// Whether any non-empty `model` name is accepted, instead of only the
253    /// served model.
254    pub fn accepts_any_model(&self) -> bool {
255        self.accept_any_model
256    }
257
258    /// How many batched readouts fell back to sequential requests because the
259    /// endpoint rejected the array-`prompt` shape.
260    pub fn batch_fallbacks(&self) -> u64 {
261        self.batch_fallbacks.load(Ordering::Relaxed)
262    }
263
264    /// Run the readout self-check: a few questions whose answers are obvious,
265    /// read through the exact slot channel every real request uses.
266    ///
267    /// It cannot prove the model is good at decisions — that needs a
268    /// gold-labelled workload, which is [`crate::evaluate`]'s job — but it
269    /// catches the model that is not answering the question at all, the
270    /// failure mode that would otherwise be silently confident.
271    pub async fn run_readout_check(&self) -> Result<crate::readout::ReadoutCheck> {
272        let questions = crate::readout::questions();
273        let scored = self
274            .score_questions(&json!(crate::readout::PROBE_STATE), &questions)
275            .await
276            .context("the readout self-check could not be scored")?;
277        let results = crate::readout::PROBES
278            .iter()
279            .zip(&scored)
280            .map(|(probe, score)| {
281                crate::readout::outcome(probe, &score.answer.option_ids, &score.answer.probabilities)
282            })
283            .collect();
284        Ok(crate::readout::ReadoutCheck::from_results(results))
285    }
286
287    /// Resolved token id for each of the sixteen answer letters.
288    pub fn slots(&self) -> &[u32] {
289        &self.slots
290    }
291
292    /// The model name this bridge sends upstream.
293    pub fn upstream_model(&self) -> &str {
294        &self.upstream_model
295    }
296
297    /// Template variables forwarded on every render.
298    pub fn chat_template_kwargs(&self) -> Value {
299        self.template.chat_template_kwargs()
300    }
301
302    /// Whether the prompt is rendered in-process rather than by the runtime.
303    pub fn renders_locally(&self) -> bool {
304        self.template.renders_locally()
305    }
306
307    /// Whether tokenization happens in-process rather than by the runtime.
308    pub fn tokenizes_locally(&self) -> bool {
309        self.template.tokenizes_locally()
310    }
311
312    /// Score one decision that carries its own state (the `--score` shape).
313    pub async fn score_row(&self, row: &Row) -> Result<DetailedScore> {
314        let question = Question {
315            id: row.id.clone(),
316            question: row.question.clone(),
317            options: row.options.clone(),
318        };
319        let mut scored = self.score_questions(&row.state, &[question]).await?;
320        Ok(scored.remove(0))
321    }
322
323    /// Score several questions that share one state (the System One shape).
324    ///
325    /// The state is serialised once and reused by every question. When the
326    /// negotiated transport accepts an array `prompt` and more than one
327    /// question is asked, the whole batch is read in **one** request, so a
328    /// prefix-caching server prefills the shared state once. A transport that
329    /// rejects the batched shape falls back to sequential single-prompt calls;
330    /// the fallback is counted (`GET /health` reports it) because a run served
331    /// that way did not get the shared prefill.
332    pub async fn score_questions(
333        &self,
334        state: &Value,
335        questions: &[Question],
336    ) -> Result<Vec<DetailedScore>> {
337        let _guard = self.lock.lock().await;
338
339        // Render up front: the evidence text is serialised once for the whole
340        // batch, and every question reuses it.
341        let evidence = evidence_json(state);
342        let mut prompts = Vec::with_capacity(questions.len());
343        for question in questions {
344            let messages =
345                direct_messages_reusing(&evidence, &question.question, &question.options);
346            let prompt = self
347                .template
348                .render(&messages)
349                .await
350                .with_context(|| format!("question {:?}", question.id))?;
351            prompts.push(prompt);
352        }
353
354        let readouts = if questions.len() > 1 && self.probe.supports_batch() {
355            match self.read_batch(questions, &prompts).await {
356                Ok(readouts) => readouts,
357                Err(error) => {
358                    // Only a *request-shape* rejection means "this endpoint
359                    // cannot batch". Anything else is about the call itself
360                    // and must propagate.
361                    if error
362                        .downcast_ref::<UpstreamStatus>()
363                        .is_some_and(UpstreamStatus::is_shape_rejection)
364                    {
365                        self.batch_fallbacks.fetch_add(1, Ordering::Relaxed);
366                        eprintln!(
367                            "the upstream rejected the batched readout shape ({error}); falling \
368                             back to {} sequential requests (no shared prefill on this endpoint)",
369                            questions.len()
370                        );
371                        self.read_one_by_one(questions, &prompts).await?
372                    } else {
373                        return Err(error);
374                    }
375                }
376            }
377        } else {
378            self.read_one_by_one(questions, &prompts).await?
379        };
380
381        // Assemble: every answer is derived from its own readout only.
382        Ok(readouts
383            .into_iter()
384            .zip(questions)
385            .zip(&prompts)
386            .map(|((readout, question), prompt)| DetailedScore {
387                answer: ScoredAnswer {
388                    id: question.id.clone(),
389                    option_ids: question
390                        .options
391                        .iter()
392                        .map(|option| option.id.clone())
393                        .collect(),
394                    probabilities: softmax(&readout.logprobs),
395                    input_tokens: readout.input_tokens,
396                    prompt_version: Some(PROMPT_VERSION.to_string()),
397                },
398                option_logprobs: readout.logprobs,
399                prompt_sha256: prompt_sha256(prompt),
400            })
401            .collect())
402    }
403
404    /// One batched readout for every question, in request order.
405    ///
406    /// A missing option token deepens the request instead of failing it: the
407    /// first attempt sizes top-k from the option count, and a large prompt can
408    /// push an answer letter past it (measured: the tetris demo's sixteen
409    /// options missed `F` at k=64). A retry reuses the server's prefix cache,
410    /// so only the readout is recomputed.
411    async fn read_batch(&self, questions: &[Question], prompts: &[String]) -> Result<Vec<Readout>> {
412        // One `logprobs` value covers every prompt of the request, so it is
413        // sized for the largest declared option set; each question then reads
414        // only its own slots from its own candidate list.
415        let max_options = questions
416            .iter()
417            .map(|question| question.options.len())
418            .max()
419            .unwrap_or(0);
420        let slots = &self.slots[..max_options];
421        let url = request_url(self.probe, &self.openai_base, &self.native_base);
422
423        let mut last_error = None;
424        for candidates_requested in candidate_ladder(self.probe, max_options) {
425            let body = build_body_with_candidates(
426                self.probe,
427                &self.upstream_model,
428                prompts,
429                slots,
430                candidates_requested,
431            );
432            let response: CompletionResponse =
433                post_json(&self.client, &url, &body, self.upstream_key.as_deref()).await?;
434
435            // The endpoint reports one usage block for the whole batch, and
436            // endpoints disagree on what it counts (llama.cpp b11096 counts the
437            // shared prefix once). It is attached to the first readout so summing
438            // the vector reproduces the endpoint's own number.
439            let batch_tokens = input_tokens(self.probe, &response);
440
441            // A batched prompt cannot be checked against `max_input_tokens` per
442            // prompt: the only number the endpoint gives is the batch total. The
443            // loose upper bound below still catches a request that is over budget
444            // even if every prompt shared one prefix.
445            if let Some(limit) = self.max_input_tokens
446                && batch_tokens > limit as u64 * questions.len() as u64
447            {
448                bail!(
449                    "the batched readout reports {batch_tokens} input tokens, over {} x {limit}; \
450                     no truncation allowed",
451                    questions.len()
452                );
453            }
454
455            let candidates = parse_batch_candidates(self.probe, &response, questions.len())?;
456            let mut readouts = Vec::with_capacity(questions.len());
457            let mut missing = None;
458            for (index, (question, candidates)) in questions.iter().zip(&candidates).enumerate() {
459                let count = question.options.len();
460                let slots = &self.slots[..count];
461                let letters: Vec<char> = LETTERS.chars().take(count).collect();
462                match slot_logprobs_from_candidates(candidates, slots, &letters) {
463                    Ok(logprobs) => readouts.push(Readout {
464                        logprobs,
465                        input_tokens: if index == 0 { batch_tokens } else { 0 },
466                    }),
467                    Err(error) => {
468                        missing = Some(error.context(format!("question {:?}", question.id)));
469                        break;
470                    }
471                }
472            }
473            match missing {
474                None => return Ok(readouts),
475                // A restricted distribution is already told which tokens to
476                // return; a missing one there is not fixed by asking deeper.
477                Some(error) if !self.probe.restricts_distribution() => {
478                    eprintln!(
479                        "{error:#}; deepening the batched readout past {candidates_requested} \
480                         candidates"
481                    );
482                    last_error = Some(error);
483                }
484                Some(error) => return Err(error),
485            }
486        }
487        Err(last_error.expect("a failed ladder records its last error"))
488    }
489
490    /// One readout per question, one upstream request each.
491    ///
492    /// A missing option token deepens the per-question request the same way
493    /// [`Self::read_batch`] does.
494    async fn read_one_by_one(
495        &self,
496        questions: &[Question],
497        prompts: &[String],
498    ) -> Result<Vec<Readout>> {
499        let mut readouts = Vec::with_capacity(questions.len());
500        for (question, prompt) in questions.iter().zip(prompts) {
501            let count = question.options.len();
502            let slots = &self.slots[..count];
503            let letters: Vec<char> = LETTERS.chars().take(count).collect();
504            let url = request_url(self.probe, &self.openai_base, &self.native_base);
505
506            let mut last_error = None;
507            let mut readout = None;
508            for candidates_requested in candidate_ladder(self.probe, count) {
509                let body = build_body_with_candidates(
510                    self.probe,
511                    &self.upstream_model,
512                    std::slice::from_ref(prompt),
513                    slots,
514                    candidates_requested,
515                );
516                let response: CompletionResponse =
517                    post_json(&self.client, &url, &body, self.upstream_key.as_deref()).await?;
518
519                if truncated(self.probe, &response) {
520                    bail!(
521                        "question {:?}: the upstream runtime truncated the prompt; shorten the state \
522                         or raise its context size",
523                        question.id
524                    );
525                }
526                let tokens = input_tokens(self.probe, &response);
527                if let Some(limit) = self.max_input_tokens
528                    && tokens > limit as u64
529                {
530                    bail!(
531                        "question {:?}: {tokens} input tokens exceed limit {limit}; no truncation \
532                         allowed",
533                        question.id
534                    );
535                }
536
537                match parse_logprobs(self.probe, &response, slots, &letters) {
538                    Ok(logprobs) => {
539                        readout = Some(Readout {
540                            logprobs,
541                            input_tokens: tokens,
542                        });
543                        break;
544                    }
545                    Err(error) if !self.probe.restricts_distribution() => {
546                        eprintln!(
547                            "question {:?}: {error:#}; deepening the readout past \
548                             {candidates_requested} candidates",
549                            question.id
550                        );
551                        last_error = Some(error.context(format!("question {:?}", question.id)));
552                    }
553                    Err(error) => {
554                        return Err(error).with_context(|| format!("question {:?}", question.id));
555                    }
556                }
557            }
558            readouts.push(
559                readout.ok_or_else(|| last_error.expect("a failed ladder records its last error"))?,
560            );
561        }
562        Ok(readouts)
563    }
564}
565
566async fn try_probe(
567    client: &reqwest::Client,
568    probe: Probe,
569    config: &BridgeConfig,
570    prompt: &str,
571    slots: &[u32],
572    letters: &[char],
573) -> Result<()> {
574    let url = request_url(probe, &config.openai_base, &config.native_base);
575    // A probe can miss an answer token for the same reason a readout can, so it
576    // walks the ladder too: a transport must not be declared unsupported just
577    // because its first candidate list was too shallow.
578    let mut last_error = None;
579    for candidates_requested in candidate_ladder(probe, slots.len()) {
580        let body = build_body_with_candidates(
581            probe,
582            &config.upstream_model,
583            &[prompt.to_string()],
584            slots,
585            candidates_requested,
586        );
587        let response: CompletionResponse =
588            post_json(client, &url, &body, config.upstream_key.as_deref()).await?;
589        match parse_logprobs(probe, &response, slots, letters) {
590            Ok(logprobs) => {
591                crate::strategy::validate_probe(&logprobs)?;
592                if probe.restricts_distribution() {
593                    let candidates = parse_candidates(probe, &response)?;
594                    if !candidates_restricted_to(&candidates, slots, letters) {
595                        bail!(
596                            "the server ignored the distribution restriction and returned foreign tokens, so \
597                             this transport would not be what it claims"
598                        );
599                    }
600                }
601                return Ok(());
602            }
603            // Only an unrestricted distribution can be fixed by asking deeper;
604            // a restricted one that misses a slot is misreporting itself.
605            Err(error) if !probe.restricts_distribution() => {
606                last_error = Some(error);
607            }
608            Err(error) => return Err(error),
609        }
610    }
611    Err(last_error.expect("a failed ladder records its last error"))
612}
613
614/// POST a typed request body and decode a typed response.
615///
616/// The bridge always knows which shape it expects, so the response is decoded
617/// into a struct instead of being poked at as a generic document; a server that
618/// answers with something else fails here rather than later.
619pub async fn post_json<T: serde::de::DeserializeOwned>(
620    client: &reqwest::Client,
621    url: &str,
622    body: &(impl serde::Serialize + ?Sized),
623    api_key: Option<&str>,
624) -> Result<T> {
625    let mut request = client.post(url).json(body);
626    if let Some(key) = api_key {
627        request = request.bearer_auth(key);
628    }
629    let response = request
630        .send()
631        .await
632        .with_context(|| format!("POST {url} failed"))?;
633    let status = response.status();
634    let text = response
635        .text()
636        .await
637        .with_context(|| format!("reading the {url} response failed"))?;
638    if !status.is_success() {
639        return Err(anyhow::Error::new(UpstreamStatus {
640            url: url.to_string(),
641            status: status.as_u16(),
642            body: text.chars().take(400).collect(),
643        }));
644    }
645    serde_json::from_str(&text).with_context(|| {
646        format!(
647            "POST {url} returned an unexpected body: {}",
648            text.chars().take(400).collect::<String>()
649        )
650    })
651}
652
653/// Shared HTTP state.
654pub struct AppState {
655    /// The configured bridge, shared by every handler.
656    pub bridge: Bridge,
657    /// When set, clients must present this bearer token.
658    pub api_key: Option<String>,
659    /// The startup readout self-check, when one ran.
660    pub readout_check: Option<crate::readout::ReadoutCheck>,
661}
662
663/// The HTTP surface: `/v1/systemone`, `/v1/models` and `/health`.
664pub fn router(state: Arc<AppState>) -> Router {
665    Router::new()
666        .route("/v1/systemone", post(systemone))
667        .route("/v1/models", get(models))
668        .route("/health", get(health))
669        .with_state(state)
670}
671
672async fn systemone(State(state): State<Arc<AppState>>, headers: HeaderMap, body: Bytes) -> Response {
673    if let Err(response) = authorize(&state, &headers) {
674        return response;
675    }
676    let payload: Value = match serde_json::from_slice(&body) {
677        Ok(value) => value,
678        Err(error) => {
679            return error_response(
680                StatusCode::BAD_REQUEST,
681                format!("body: must be a JSON object ({error})"),
682            )
683        }
684    };
685    let batch = match request_batch(
686        &payload,
687        &state.bridge.served_model,
688        state.bridge.accepts_any_model(),
689    ) {
690        Ok(parsed) => parsed,
691        Err(error) => return error_response(StatusCode::BAD_REQUEST, error.0),
692    };
693    let results = match state
694        .bridge
695        .score_questions(&batch.state, &batch.questions)
696        .await
697    {
698        Ok(results) => results,
699        Err(error) => {
700            return error_response(StatusCode::BAD_GATEWAY, format!("{error:#}"));
701        }
702    };
703    let answers: Vec<ScoredAnswer> = results.into_iter().map(|scored| scored.answer).collect();
704    match response_from_results(
705        &state.bridge.served_model,
706        &batch.specs,
707        &answers,
708        state.bridge.readout(),
709    ) {
710        Ok(response) => Json(response).into_response(),
711        Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error.0),
712    }
713}
714
715/// One entry in `GET /v1/models`.
716#[derive(Debug, Serialize)]
717struct ModelEntry {
718    name: String,
719    description: String,
720    release_date: String,
721}
722
723#[derive(Debug, Serialize)]
724struct ModelsResponse {
725    models: Vec<ModelEntry>,
726}
727
728/// What the bridge negotiated, exposed so an operator can see it rather than
729/// guess which transport is in use.
730#[derive(Debug, Serialize)]
731struct HealthResponse {
732    status: &'static str,
733    served_model: String,
734    upstream_model: String,
735    probe: &'static str,
736    probe_note: &'static str,
737    endpoint: &'static str,
738    prompt_version: &'static str,
739    /// The resolved token id of every answer letter.
740    answer_slots: OrderedMap<u32>,
741    chat_template_kwargs: Value,
742    renders_locally: bool,
743    tokenizes_locally: bool,
744    probability_status: &'static str,
745    /// Whether the readout channel was validated on the served model.
746    readout: ReadoutStatus,
747    /// Batched readouts served by the sequential fallback (0 = every batch got
748    /// the shared prefill).
749    batch_fallbacks: u64,
750    /// The startup readout self-check, when one ran.
751    #[serde(skip_serializing_if = "Option::is_none")]
752    readout_check: Option<crate::readout::ReadoutCheck>,
753}
754
755async fn models(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
756    if let Err(response) = authorize(&state, &headers) {
757        return response;
758    }
759    Json(ModelsResponse {
760        models: vec![ModelEntry {
761            name: state.bridge.served_model.clone(),
762            description: state.bridge.description.clone(),
763            release_date: state.bridge.release_date.clone(),
764        }],
765    })
766    .into_response()
767}
768
769async fn health(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
770    if let Err(response) = authorize(&state, &headers) {
771        return response;
772    }
773    let mut answer_slots = OrderedMap::new();
774    for (letter, id) in LETTERS.chars().zip(state.bridge.slots()) {
775        answer_slots.insert(letter.to_string(), *id);
776    }
777    Json(HealthResponse {
778        status: "ok",
779        served_model: state.bridge.served_model.clone(),
780        upstream_model: state.bridge.upstream_model().to_string(),
781        probe: state.bridge.probe().name(),
782        probe_note: state.bridge.probe().note(),
783        endpoint: state.bridge.probe().endpoint(),
784        prompt_version: PROMPT_VERSION,
785        answer_slots,
786        chat_template_kwargs: state.bridge.chat_template_kwargs(),
787        renders_locally: state.bridge.renders_locally(),
788        tokenizes_locally: state.bridge.tokenizes_locally(),
789        probability_status: PROBABILITY_STATUS,
790        readout: state.bridge.readout().clone(),
791        batch_fallbacks: state.bridge.batch_fallbacks(),
792        readout_check: state.readout_check.clone(),
793    })
794    .into_response()
795}
796
797fn error_response(status: StatusCode, message: String) -> Response {
798    (status, Json(json!({"error": message}))).into_response()
799}
800
801/// Rejecting a request returns a full HTTP response, which is larger than the
802/// success value; boxing it would only add an allocation on the error path.
803#[allow(clippy::result_large_err)]
804fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
805    let Some(expected) = &state.api_key else {
806        return Ok(());
807    };
808    let presented = headers
809        .get(AUTHORIZATION)
810        .and_then(|value| value.to_str().ok())
811        .and_then(|value| value.strip_prefix("Bearer "))
812        .unwrap_or_default();
813    if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
814        Ok(())
815    } else {
816        Err(error_response(
817            StatusCode::UNAUTHORIZED,
818            "missing or invalid bearer token".to_string(),
819        ))
820    }
821}
822
823fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
824    if left.len() != right.len() {
825        return false;
826    }
827    left.iter()
828        .zip(right)
829        .fold(0u8, |accumulator, (a, b)| accumulator | (a ^ b))
830        == 0
831}