1use 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, candidates_restricted_to, input_tokens, parse_batch_candidates, parse_candidates,
24 parse_logprobs, request_url, slot_logprobs_from_candidates, truncated, CompletionResponse,
25 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
33pub 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 readout: ReadoutStatus,
49 batch_fallbacks: AtomicU64,
53 lock: Mutex<()>,
55}
56
57struct Readout {
59 logprobs: Vec<f64>,
61 input_tokens: u64,
66}
67
68#[derive(Debug)]
76pub struct UpstreamStatus {
77 pub url: String,
79 pub status: u16,
81 pub body: String,
83}
84
85impl UpstreamStatus {
86 pub fn is_shape_rejection(&self) -> bool {
89 matches!(self.status, 400 | 404 | 405 | 415 | 422)
90 }
91}
92
93impl std::fmt::Display for UpstreamStatus {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 write!(f, "POST {} returned HTTP {}: {}", self.url, self.status, self.body)
96 }
97}
98
99impl std::error::Error for UpstreamStatus {}
100
101pub struct BridgeConfig {
103 pub openai_base: String,
105 pub native_base: String,
107 pub upstream_model: String,
109 pub upstream_key: Option<String>,
111 pub served_model: String,
113 pub description: String,
115 pub release_date: String,
117 pub chat_template_kwargs: Value,
119 pub max_input_tokens: Option<usize>,
121 pub local: LocalComponents,
124 pub readout: ReadoutStatus,
130}
131
132#[derive(Debug, Clone)]
135pub struct DetailedScore {
136 pub answer: ScoredAnswer,
138 pub option_logprobs: Vec<f64>,
140 pub prompt_sha256: String,
142}
143
144impl Bridge {
145 pub async fn connect(client: reqwest::Client, mut config: BridgeConfig) -> Result<Self> {
147 let runtime = RuntimeClient::new(
148 client.clone(),
149 config.native_base.clone(),
150 config.upstream_model.clone(),
151 config.upstream_key.clone(),
152 );
153 let template = ChatTemplate::new(
154 runtime,
155 std::mem::take(&mut config.local),
156 config.chat_template_kwargs.clone(),
157 );
158
159 let slots = template
160 .resolve_slots()
161 .await
162 .context("resolving the single-token answer slots failed")?;
163
164 let probe_options: Vec<crate::wire::RowOption> = LETTERS
167 .chars()
168 .map(|letter| crate::wire::RowOption {
169 id: letter.to_string(),
170 description: format!("Probe option {letter}"),
171 })
172 .collect();
173 let messages = direct_messages(&json!("probe"), "probe", &probe_options);
174 let prompt = template
175 .render(&messages)
176 .await
177 .context("rendering the probe prompt failed")?;
178 template
179 .verify_boundary(&prompt, &slots)
180 .await
181 .context("verifying the answer-token boundary failed")?;
182
183 let letters: Vec<char> = LETTERS.chars().collect();
184 let mut chosen = None;
185 let mut failures = Vec::new();
186 for probe in crate::strategy::CANDIDATES {
187 match try_probe(
188 &client,
189 probe,
190 &config,
191 &prompt,
192 &slots,
193 &letters,
194 )
195 .await
196 {
197 Ok(()) => {
198 chosen = Some(probe);
199 break;
200 }
201 Err(error) => failures.push(format!("{}: {error:#}", probe.name())),
202 }
203 }
204 let probe = chosen.ok_or_else(|| {
205 anyhow::anyhow!(
206 "no supported transport answered the probe:\n {}",
207 failures.join("\n ")
208 )
209 })?;
210
211 Ok(Self {
212 client,
213 template,
214 probe,
215 upstream_model: config.upstream_model,
216 upstream_key: config.upstream_key,
217 openai_base: config.openai_base,
218 native_base: config.native_base,
219 slots,
220 served_model: config.served_model,
221 description: config.description,
222 release_date: config.release_date,
223 max_input_tokens: config.max_input_tokens,
224 readout: config.readout,
225 batch_fallbacks: AtomicU64::new(0),
226 lock: Mutex::new(()),
227 })
228 }
229
230 pub fn probe(&self) -> Probe {
232 self.probe
233 }
234
235 pub fn readout(&self) -> &ReadoutStatus {
237 &self.readout
238 }
239
240 pub fn batch_fallbacks(&self) -> u64 {
243 self.batch_fallbacks.load(Ordering::Relaxed)
244 }
245
246 pub async fn run_readout_check(&self) -> Result<crate::readout::ReadoutCheck> {
254 let questions = crate::readout::questions();
255 let scored = self
256 .score_questions(&json!(crate::readout::PROBE_STATE), &questions)
257 .await
258 .context("the readout self-check could not be scored")?;
259 let results = crate::readout::PROBES
260 .iter()
261 .zip(&scored)
262 .map(|(probe, score)| {
263 crate::readout::outcome(probe, &score.answer.option_ids, &score.answer.probabilities)
264 })
265 .collect();
266 Ok(crate::readout::ReadoutCheck::from_results(results))
267 }
268
269 pub fn slots(&self) -> &[u32] {
271 &self.slots
272 }
273
274 pub fn upstream_model(&self) -> &str {
276 &self.upstream_model
277 }
278
279 pub fn chat_template_kwargs(&self) -> Value {
281 self.template.chat_template_kwargs()
282 }
283
284 pub fn renders_locally(&self) -> bool {
286 self.template.renders_locally()
287 }
288
289 pub fn tokenizes_locally(&self) -> bool {
291 self.template.tokenizes_locally()
292 }
293
294 pub async fn score_row(&self, row: &Row) -> Result<DetailedScore> {
296 let question = Question {
297 id: row.id.clone(),
298 question: row.question.clone(),
299 options: row.options.clone(),
300 };
301 let mut scored = self.score_questions(&row.state, &[question]).await?;
302 Ok(scored.remove(0))
303 }
304
305 pub async fn score_questions(
315 &self,
316 state: &Value,
317 questions: &[Question],
318 ) -> Result<Vec<DetailedScore>> {
319 let _guard = self.lock.lock().await;
320
321 let evidence = evidence_json(state);
324 let mut prompts = Vec::with_capacity(questions.len());
325 for question in questions {
326 let messages =
327 direct_messages_reusing(&evidence, &question.question, &question.options);
328 let prompt = self
329 .template
330 .render(&messages)
331 .await
332 .with_context(|| format!("question {:?}", question.id))?;
333 prompts.push(prompt);
334 }
335
336 let readouts = if questions.len() > 1 && self.probe.supports_batch() {
337 match self.read_batch(questions, &prompts).await {
338 Ok(readouts) => readouts,
339 Err(error) => {
340 if error
344 .downcast_ref::<UpstreamStatus>()
345 .is_some_and(UpstreamStatus::is_shape_rejection)
346 {
347 self.batch_fallbacks.fetch_add(1, Ordering::Relaxed);
348 eprintln!(
349 "the upstream rejected the batched readout shape ({error}); falling \
350 back to {} sequential requests (no shared prefill on this endpoint)",
351 questions.len()
352 );
353 self.read_one_by_one(questions, &prompts).await?
354 } else {
355 return Err(error);
356 }
357 }
358 }
359 } else {
360 self.read_one_by_one(questions, &prompts).await?
361 };
362
363 Ok(readouts
365 .into_iter()
366 .zip(questions)
367 .zip(&prompts)
368 .map(|((readout, question), prompt)| DetailedScore {
369 answer: ScoredAnswer {
370 id: question.id.clone(),
371 option_ids: question
372 .options
373 .iter()
374 .map(|option| option.id.clone())
375 .collect(),
376 probabilities: softmax(&readout.logprobs),
377 input_tokens: readout.input_tokens,
378 prompt_version: Some(PROMPT_VERSION.to_string()),
379 },
380 option_logprobs: readout.logprobs,
381 prompt_sha256: prompt_sha256(prompt),
382 })
383 .collect())
384 }
385
386 async fn read_batch(&self, questions: &[Question], prompts: &[String]) -> Result<Vec<Readout>> {
388 let max_options = questions
392 .iter()
393 .map(|question| question.options.len())
394 .max()
395 .unwrap_or(0);
396 let slots = &self.slots[..max_options];
397 let body = build_body(self.probe, &self.upstream_model, prompts, slots);
398 let url = request_url(self.probe, &self.openai_base, &self.native_base);
399 let response: CompletionResponse =
400 post_json(&self.client, &url, &body, self.upstream_key.as_deref()).await?;
401
402 let batch_tokens = input_tokens(self.probe, &response);
407
408 if let Some(limit) = self.max_input_tokens
413 && batch_tokens > limit as u64 * questions.len() as u64
414 {
415 bail!(
416 "the batched readout reports {batch_tokens} input tokens, over {} x {limit}; \
417 no truncation allowed",
418 questions.len()
419 );
420 }
421
422 let candidates = parse_batch_candidates(self.probe, &response, questions.len())?;
423 let mut readouts = Vec::with_capacity(questions.len());
424 for (index, (question, candidates)) in questions.iter().zip(&candidates).enumerate() {
425 let count = question.options.len();
426 let slots = &self.slots[..count];
427 let letters: Vec<char> = LETTERS.chars().take(count).collect();
428 let logprobs = slot_logprobs_from_candidates(candidates, slots, &letters)
429 .with_context(|| format!("question {:?}", question.id))?;
430 readouts.push(Readout {
431 logprobs,
432 input_tokens: if index == 0 { batch_tokens } else { 0 },
433 });
434 }
435 Ok(readouts)
436 }
437
438 async fn read_one_by_one(
440 &self,
441 questions: &[Question],
442 prompts: &[String],
443 ) -> Result<Vec<Readout>> {
444 let mut readouts = Vec::with_capacity(questions.len());
445 for (question, prompt) in questions.iter().zip(prompts) {
446 let count = question.options.len();
447 let slots = &self.slots[..count];
448 let letters: Vec<char> = LETTERS.chars().take(count).collect();
449 let body = build_body(
450 self.probe,
451 &self.upstream_model,
452 std::slice::from_ref(prompt),
453 slots,
454 );
455 let url = request_url(self.probe, &self.openai_base, &self.native_base);
456 let response: CompletionResponse =
457 post_json(&self.client, &url, &body, self.upstream_key.as_deref()).await?;
458
459 if truncated(self.probe, &response) {
460 bail!(
461 "question {:?}: the upstream runtime truncated the prompt; shorten the state \
462 or raise its context size",
463 question.id
464 );
465 }
466 let tokens = input_tokens(self.probe, &response);
467 if let Some(limit) = self.max_input_tokens
468 && tokens > limit as u64
469 {
470 bail!(
471 "question {:?}: {tokens} input tokens exceed limit {limit}; no truncation \
472 allowed",
473 question.id
474 );
475 }
476
477 let logprobs = parse_logprobs(self.probe, &response, slots, &letters)
478 .with_context(|| format!("question {:?}", question.id))?;
479 readouts.push(Readout {
480 logprobs,
481 input_tokens: tokens,
482 });
483 }
484 Ok(readouts)
485 }
486}
487
488async fn try_probe(
489 client: &reqwest::Client,
490 probe: Probe,
491 config: &BridgeConfig,
492 prompt: &str,
493 slots: &[u32],
494 letters: &[char],
495) -> Result<()> {
496 let body = build_body(probe, &config.upstream_model, &[prompt.to_string()], slots);
497 let url = request_url(probe, &config.openai_base, &config.native_base);
498 let response: CompletionResponse = post_json(client, &url, &body, config.upstream_key.as_deref()).await?;
499 let logprobs = parse_logprobs(probe, &response, slots, letters)?;
500 crate::strategy::validate_probe(&logprobs)?;
501 if probe.restricts_distribution() {
502 let candidates = parse_candidates(probe, &response)?;
503 if !candidates_restricted_to(&candidates, slots, letters) {
504 bail!(
505 "the server ignored the distribution restriction and returned foreign tokens, so \
506 this transport would not be what it claims"
507 );
508 }
509 }
510 Ok(())
511}
512
513pub async fn post_json<T: serde::de::DeserializeOwned>(
519 client: &reqwest::Client,
520 url: &str,
521 body: &(impl serde::Serialize + ?Sized),
522 api_key: Option<&str>,
523) -> Result<T> {
524 let mut request = client.post(url).json(body);
525 if let Some(key) = api_key {
526 request = request.bearer_auth(key);
527 }
528 let response = request
529 .send()
530 .await
531 .with_context(|| format!("POST {url} failed"))?;
532 let status = response.status();
533 let text = response
534 .text()
535 .await
536 .with_context(|| format!("reading the {url} response failed"))?;
537 if !status.is_success() {
538 return Err(anyhow::Error::new(UpstreamStatus {
539 url: url.to_string(),
540 status: status.as_u16(),
541 body: text.chars().take(400).collect(),
542 }));
543 }
544 serde_json::from_str(&text).with_context(|| {
545 format!(
546 "POST {url} returned an unexpected body: {}",
547 text.chars().take(400).collect::<String>()
548 )
549 })
550}
551
552pub struct AppState {
554 pub bridge: Bridge,
556 pub api_key: Option<String>,
558 pub readout_check: Option<crate::readout::ReadoutCheck>,
560}
561
562pub fn router(state: Arc<AppState>) -> Router {
564 Router::new()
565 .route("/v1/systemone", post(systemone))
566 .route("/v1/models", get(models))
567 .route("/health", get(health))
568 .with_state(state)
569}
570
571async fn systemone(State(state): State<Arc<AppState>>, headers: HeaderMap, body: Bytes) -> Response {
572 if let Err(response) = authorize(&state, &headers) {
573 return response;
574 }
575 let payload: Value = match serde_json::from_slice(&body) {
576 Ok(value) => value,
577 Err(error) => {
578 return error_response(
579 StatusCode::BAD_REQUEST,
580 format!("body: must be a JSON object ({error})"),
581 )
582 }
583 };
584 let batch = match request_batch(&payload, &state.bridge.served_model) {
585 Ok(parsed) => parsed,
586 Err(error) => return error_response(StatusCode::BAD_REQUEST, error.0),
587 };
588 let results = match state
589 .bridge
590 .score_questions(&batch.state, &batch.questions)
591 .await
592 {
593 Ok(results) => results,
594 Err(error) => {
595 return error_response(StatusCode::BAD_GATEWAY, format!("{error:#}"));
596 }
597 };
598 let answers: Vec<ScoredAnswer> = results.into_iter().map(|scored| scored.answer).collect();
599 match response_from_results(
600 &state.bridge.served_model,
601 &batch.specs,
602 &answers,
603 state.bridge.readout(),
604 ) {
605 Ok(response) => Json(response).into_response(),
606 Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error.0),
607 }
608}
609
610#[derive(Debug, Serialize)]
612struct ModelEntry {
613 name: String,
614 description: String,
615 release_date: String,
616}
617
618#[derive(Debug, Serialize)]
619struct ModelsResponse {
620 models: Vec<ModelEntry>,
621}
622
623#[derive(Debug, Serialize)]
626struct HealthResponse {
627 status: &'static str,
628 served_model: String,
629 upstream_model: String,
630 probe: &'static str,
631 probe_note: &'static str,
632 endpoint: &'static str,
633 prompt_version: &'static str,
634 answer_slots: OrderedMap<u32>,
636 chat_template_kwargs: Value,
637 renders_locally: bool,
638 tokenizes_locally: bool,
639 probability_status: &'static str,
640 readout: ReadoutStatus,
642 batch_fallbacks: u64,
645 #[serde(skip_serializing_if = "Option::is_none")]
647 readout_check: Option<crate::readout::ReadoutCheck>,
648}
649
650async fn models(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
651 if let Err(response) = authorize(&state, &headers) {
652 return response;
653 }
654 Json(ModelsResponse {
655 models: vec![ModelEntry {
656 name: state.bridge.served_model.clone(),
657 description: state.bridge.description.clone(),
658 release_date: state.bridge.release_date.clone(),
659 }],
660 })
661 .into_response()
662}
663
664async fn health(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
665 if let Err(response) = authorize(&state, &headers) {
666 return response;
667 }
668 let mut answer_slots = OrderedMap::new();
669 for (letter, id) in LETTERS.chars().zip(state.bridge.slots()) {
670 answer_slots.insert(letter.to_string(), *id);
671 }
672 Json(HealthResponse {
673 status: "ok",
674 served_model: state.bridge.served_model.clone(),
675 upstream_model: state.bridge.upstream_model().to_string(),
676 probe: state.bridge.probe().name(),
677 probe_note: state.bridge.probe().note(),
678 endpoint: state.bridge.probe().endpoint(),
679 prompt_version: PROMPT_VERSION,
680 answer_slots,
681 chat_template_kwargs: state.bridge.chat_template_kwargs(),
682 renders_locally: state.bridge.renders_locally(),
683 tokenizes_locally: state.bridge.tokenizes_locally(),
684 probability_status: PROBABILITY_STATUS,
685 readout: state.bridge.readout().clone(),
686 batch_fallbacks: state.bridge.batch_fallbacks(),
687 readout_check: state.readout_check.clone(),
688 })
689 .into_response()
690}
691
692fn error_response(status: StatusCode, message: String) -> Response {
693 (status, Json(json!({"error": message}))).into_response()
694}
695
696#[allow(clippy::result_large_err)]
699fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
700 let Some(expected) = &state.api_key else {
701 return Ok(());
702 };
703 let presented = headers
704 .get(AUTHORIZATION)
705 .and_then(|value| value.to_str().ok())
706 .and_then(|value| value.strip_prefix("Bearer "))
707 .unwrap_or_default();
708 if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
709 Ok(())
710 } else {
711 Err(error_response(
712 StatusCode::UNAUTHORIZED,
713 "missing or invalid bearer token".to_string(),
714 ))
715 }
716}
717
718fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
719 if left.len() != right.len() {
720 return false;
721 }
722 left.iter()
723 .zip(right)
724 .fold(0u8, |accumulator, (a, b)| accumulator | (a ^ b))
725 == 0
726}