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_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
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 accept_any_model: bool,
49 readout: ReadoutStatus,
51 batch_fallbacks: AtomicU64,
55 lock: Mutex<()>,
57}
58
59struct Readout {
61 logprobs: Vec<f64>,
63 input_tokens: u64,
68}
69
70#[derive(Debug)]
78pub struct UpstreamStatus {
79 pub url: String,
81 pub status: u16,
83 pub body: String,
85}
86
87impl UpstreamStatus {
88 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
103pub struct BridgeConfig {
105 pub openai_base: String,
107 pub native_base: String,
109 pub upstream_model: String,
111 pub upstream_key: Option<String>,
113 pub served_model: String,
115 pub description: String,
117 pub release_date: String,
119 pub chat_template_kwargs: Value,
121 pub max_input_tokens: Option<usize>,
123 pub local: LocalComponents,
126 pub accept_any_model: bool,
135 pub readout: ReadoutStatus,
141}
142
143#[derive(Debug, Clone)]
146pub struct DetailedScore {
147 pub answer: ScoredAnswer,
149 pub option_logprobs: Vec<f64>,
151 pub prompt_sha256: String,
153}
154
155impl Bridge {
156 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 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 pub fn probe(&self) -> Probe {
244 self.probe
245 }
246
247 pub fn readout(&self) -> &ReadoutStatus {
249 &self.readout
250 }
251
252 pub fn accepts_any_model(&self) -> bool {
255 self.accept_any_model
256 }
257
258 pub fn batch_fallbacks(&self) -> u64 {
261 self.batch_fallbacks.load(Ordering::Relaxed)
262 }
263
264 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 pub fn slots(&self) -> &[u32] {
289 &self.slots
290 }
291
292 pub fn upstream_model(&self) -> &str {
294 &self.upstream_model
295 }
296
297 pub fn chat_template_kwargs(&self) -> Value {
299 self.template.chat_template_kwargs()
300 }
301
302 pub fn renders_locally(&self) -> bool {
304 self.template.renders_locally()
305 }
306
307 pub fn tokenizes_locally(&self) -> bool {
309 self.template.tokenizes_locally()
310 }
311
312 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 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 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 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 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 async fn read_batch(&self, questions: &[Question], prompts: &[String]) -> Result<Vec<Readout>> {
412 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 let batch_tokens = input_tokens(self.probe, &response);
440
441 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 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 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 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 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
614pub 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
653pub struct AppState {
655 pub bridge: Bridge,
657 pub api_key: Option<String>,
659 pub readout_check: Option<crate::readout::ReadoutCheck>,
661}
662
663pub 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#[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#[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 answer_slots: OrderedMap<u32>,
741 chat_template_kwargs: Value,
742 renders_locally: bool,
743 tokenizes_locally: bool,
744 probability_status: &'static str,
745 readout: ReadoutStatus,
747 batch_fallbacks: u64,
750 #[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#[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}