use amberfork_record::{CapturedRequest, CapturedResponse, Cassette, Exchange};
use crate::{Replay, Upstream, UpstreamError};
#[derive(Debug)]
pub struct ReplayProxy<U> {
replay: Replay,
upstream: U,
reexecuted: Cassette,
}
impl<U: Upstream> ReplayProxy<U> {
#[must_use]
pub fn new(cassette: Cassette, upstream: U, reexecuted_id: impl Into<String>) -> Self {
Self {
replay: Replay::new(cassette),
upstream,
reexecuted: Cassette::new(reexecuted_id),
}
}
pub async fn answer(
&mut self,
request: CapturedRequest,
) -> Result<CapturedResponse, UpstreamError> {
let response = match self.replay.lookup(&request) {
Some(recorded) => recorded.clone(),
None => self.upstream.send(&request).await?,
};
self.reexecuted.exchanges.push(Exchange {
idx: self.reexecuted.exchanges.len(),
request,
response: response.clone(),
});
Ok(response)
}
#[must_use]
pub fn reexecuted(&self) -> &Cassette {
&self.reexecuted
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ScriptedUpstream;
use amberfork_record::Body;
use serde_json::{Value, json};
fn request(body: Value) -> CapturedRequest {
CapturedRequest {
method: "POST".to_string(),
path: "/v1/chat/completions".to_string(),
headers: Vec::new(),
body: Body::Json(body),
}
}
fn response(body: Value) -> CapturedResponse {
CapturedResponse {
status: 200,
headers: Vec::new(),
body: Body::Json(body),
}
}
fn cassette_with(
exchanges: impl IntoIterator<Item = (CapturedRequest, CapturedResponse)>,
) -> Cassette {
let mut cassette = Cassette::new("recorded");
for (idx, (req, resp)) in exchanges.into_iter().enumerate() {
cassette.exchanges.push(Exchange {
idx,
request: req,
response: resp,
});
}
cassette
}
#[tokio::test]
async fn a_recorded_hit_serves_the_tape_and_records_the_turn() {
let req = request(json!({"messages": [{"role": "user", "content": "hi"}]}));
let resp = response(json!({"choices": [{"message": {"content": "hello"}}]}));
let mut proxy = ReplayProxy::new(
cassette_with([(req.clone(), resp.clone())]),
ScriptedUpstream::new(Vec::<CapturedResponse>::new()),
"reexec",
);
let served = proxy.answer(req.clone()).await.unwrap();
assert_eq!(served, resp);
assert_eq!(
proxy.reexecuted().exchanges,
vec![Exchange {
idx: 0,
request: req,
response: resp,
}]
);
}
#[tokio::test]
async fn a_branch_relays_upstream_and_appends_the_live_turn() {
let recorded_req = request(json!({"messages": [{"role": "user", "content": "hi"}]}));
let recorded_resp = response(json!({"choices": []}));
let branched_req =
request(json!({"messages": [{"role": "user", "content": "off the tape"}]}));
let live_resp = response(json!({"choices": [{"message": {"content": "live answer"}}]}));
let mut proxy = ReplayProxy::new(
cassette_with([(recorded_req, recorded_resp)]),
ScriptedUpstream::new([live_resp.clone()]),
"reexec",
);
let served = proxy.answer(branched_req.clone()).await.unwrap();
assert_eq!(served, live_resp);
assert_eq!(
proxy.reexecuted().exchanges,
vec![Exchange {
idx: 0,
request: branched_req,
response: live_resp,
}]
);
}
#[tokio::test]
async fn the_reexecuted_tape_keeps_replayed_then_live_turns_in_order() {
let a_req = request(json!({"messages": [{"role": "user", "content": "turn A"}]}));
let a_resp = response(json!({"choices": [{"message": {"content": "A"}}]}));
let b_req = request(json!({"messages": [{"role": "user", "content": "turn B off-tape"}]}));
let b_resp = response(json!({"choices": [{"message": {"content": "B live"}}]}));
let mut proxy = ReplayProxy::new(
cassette_with([(a_req.clone(), a_resp.clone())]),
ScriptedUpstream::new([b_resp.clone()]),
"reexec",
);
proxy.answer(a_req.clone()).await.unwrap(); proxy.answer(b_req.clone()).await.unwrap();
assert_eq!(
proxy.reexecuted().exchanges,
vec![
Exchange {
idx: 0,
request: a_req,
response: a_resp,
},
Exchange {
idx: 1,
request: b_req,
response: b_resp,
},
]
);
}
}