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