mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Enable clippy::allow_attributes_without_reason at the workspace level. Add concise, callsite-specific reasons to existing allow attributes, including generated code paths.
587 lines
16 KiB
Rust
587 lines
16 KiB
Rust
#![allow(
|
|
dead_code,
|
|
unreachable_pub,
|
|
reason = "Shared test helpers stay public within the test crate and not every helper is used everywhere."
|
|
)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::Result;
|
|
use fabro_http::header::AUTHORIZATION;
|
|
use fabro_http::{HttpClient as Client, HttpClientBuilder};
|
|
use futures_util::StreamExt;
|
|
use serde_json::Value;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use twin_openai::config::Config;
|
|
|
|
pub struct TestServer {
|
|
pub base_url: String,
|
|
pub client: Client,
|
|
pub auth_client: Client,
|
|
pub bearer_token: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ApiClient {
|
|
pub base_url: String,
|
|
client: Client,
|
|
bearer_token: Option<String>,
|
|
organization: Option<String>,
|
|
project: Option<String>,
|
|
}
|
|
|
|
pub struct RecordedResponse {
|
|
pub status: fabro_http::StatusCode,
|
|
pub headers: HashMap<String, String>,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
pub struct RawStreamResponse {
|
|
pub status: u16,
|
|
pub headers: HashMap<String, String>,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
pub struct TimedStreamResponse {
|
|
pub status: fabro_http::StatusCode,
|
|
pub first_event_elapsed: Duration,
|
|
pub chunks: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ParsedSseTranscript {
|
|
pub blocks: Vec<String>,
|
|
pub events: Vec<ParsedSseEvent>,
|
|
pub done: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ParsedSseEvent {
|
|
pub event: Option<String>,
|
|
pub data: String,
|
|
}
|
|
|
|
static NEXT_BEARER_TOKEN: AtomicU64 = AtomicU64::new(1);
|
|
|
|
pub fn test_http_client() -> Result<Client> {
|
|
fabro_http::test_http_client().map_err(Into::into)
|
|
}
|
|
|
|
pub async fn spawn_server() -> Result<TestServer> {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let addr: SocketAddr = listener.local_addr()?;
|
|
let app = twin_openai::build_app_with_config(Config {
|
|
bind_addr: "127.0.0.1:0".parse().expect("valid addr"),
|
|
require_auth: true,
|
|
enable_admin: true,
|
|
});
|
|
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.expect("server should run");
|
|
});
|
|
|
|
TestServer::new(format!("http://{addr}"), next_bearer_token())
|
|
}
|
|
|
|
fn next_bearer_token() -> String {
|
|
format!(
|
|
"test-key-{}",
|
|
NEXT_BEARER_TOKEN.fetch_add(1, Ordering::SeqCst)
|
|
)
|
|
}
|
|
|
|
fn authorization_header_value(bearer_token: &str) -> String {
|
|
format!("Bearer {bearer_token}")
|
|
}
|
|
|
|
fn build_authenticated_client(bearer_token: &str) -> Result<Client> {
|
|
HttpClientBuilder::new()
|
|
.proxy_policy(fabro_http::ProxyPolicy::Disabled)
|
|
.default_headers(
|
|
[(
|
|
AUTHORIZATION,
|
|
authorization_header_value(bearer_token)
|
|
.parse()
|
|
.expect("valid header"),
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
)
|
|
.build()
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
impl ApiClient {
|
|
pub fn new(
|
|
base_url: impl Into<String>,
|
|
bearer_token: Option<String>,
|
|
organization: Option<String>,
|
|
project: Option<String>,
|
|
) -> Result<Self> {
|
|
Ok(Self {
|
|
base_url: base_url.into(),
|
|
client: HttpClientBuilder::new()
|
|
.proxy_policy(fabro_http::ProxyPolicy::Disabled)
|
|
.timeout(Duration::from_secs(30))
|
|
.build()?,
|
|
bearer_token,
|
|
organization,
|
|
project,
|
|
})
|
|
}
|
|
|
|
pub fn with_client(
|
|
base_url: impl Into<String>,
|
|
client: Client,
|
|
bearer_token: Option<String>,
|
|
organization: Option<String>,
|
|
project: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
base_url: base_url.into(),
|
|
client,
|
|
bearer_token,
|
|
organization,
|
|
project,
|
|
}
|
|
}
|
|
|
|
pub async fn post_json(&self, path: &str, body: &Value) -> fabro_http::Response {
|
|
self.post(path)
|
|
.json(body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete")
|
|
}
|
|
|
|
pub async fn post_json_recorded(&self, path: &str, body: &Value) -> RecordedResponse {
|
|
record_response(self.post_json(path, body).await).await
|
|
}
|
|
|
|
pub async fn get_recorded(&self, path: &str) -> RecordedResponse {
|
|
record_response(
|
|
self.get(path)
|
|
.send()
|
|
.await
|
|
.expect("request should complete"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub fn post(&self, path: &str) -> fabro_http::RequestBuilder {
|
|
self.request(self.client.post(format!("{}{}", self.base_url, path)))
|
|
}
|
|
|
|
pub fn get(&self, path: &str) -> fabro_http::RequestBuilder {
|
|
self.request(self.client.get(format!("{}{}", self.base_url, path)))
|
|
}
|
|
|
|
fn request(&self, mut request: fabro_http::RequestBuilder) -> fabro_http::RequestBuilder {
|
|
if let Some(token) = &self.bearer_token {
|
|
request = request.bearer_auth(token);
|
|
}
|
|
if let Some(org) = &self.organization {
|
|
request = request.header("OpenAI-Organization", org);
|
|
}
|
|
if let Some(project) = &self.project {
|
|
request = request.header("OpenAI-Project", project);
|
|
}
|
|
|
|
request
|
|
}
|
|
}
|
|
|
|
impl TestServer {
|
|
fn new(base_url: String, bearer_token: String) -> Result<Self> {
|
|
let client = test_http_client()?;
|
|
let auth_client = build_authenticated_client(&bearer_token)?;
|
|
|
|
Ok(Self {
|
|
base_url,
|
|
client,
|
|
auth_client,
|
|
bearer_token,
|
|
})
|
|
}
|
|
|
|
pub fn authorization_header_value(&self) -> String {
|
|
authorization_header_value(&self.bearer_token)
|
|
}
|
|
|
|
pub fn api_client(&self) -> ApiClient {
|
|
ApiClient::with_client(
|
|
self.base_url.clone(),
|
|
self.client.clone(),
|
|
Some(self.bearer_token.clone()),
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
|
|
pub fn fork_namespace(&self) -> Result<Self> {
|
|
Self::new(self.base_url.clone(), next_bearer_token())
|
|
}
|
|
}
|
|
|
|
impl TestServer {
|
|
pub async fn post_responses(&self, body: Value) -> fabro_http::Response {
|
|
self.auth_client
|
|
.post(format!("{}/v1/responses", self.base_url))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete")
|
|
}
|
|
|
|
pub async fn post_responses_with_headers(
|
|
&self,
|
|
body: Value,
|
|
org: Option<&str>,
|
|
project: Option<&str>,
|
|
) -> fabro_http::Response {
|
|
let mut request = self
|
|
.auth_client
|
|
.post(format!("{}/v1/responses", self.base_url));
|
|
|
|
if let Some(org) = org {
|
|
request = request.header("OpenAI-Organization", org);
|
|
}
|
|
|
|
if let Some(project) = project {
|
|
request = request.header("OpenAI-Project", project);
|
|
}
|
|
|
|
request
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete")
|
|
}
|
|
|
|
pub async fn post_responses_stream(
|
|
&self,
|
|
body: Value,
|
|
) -> (fabro_http::StatusCode, Vec<String>) {
|
|
let response = self
|
|
.auth_client
|
|
.post(format!("{}/v1/responses", self.base_url))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete");
|
|
|
|
let status = response.status();
|
|
let mut stream = response.bytes_stream();
|
|
let mut chunks = Vec::new();
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
chunks.push(
|
|
String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"),
|
|
);
|
|
}
|
|
|
|
(status, chunks)
|
|
}
|
|
|
|
pub async fn post_chat(&self, body: Value) -> fabro_http::Response {
|
|
self.auth_client
|
|
.post(format!("{}/v1/chat/completions", self.base_url))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete")
|
|
}
|
|
|
|
pub async fn post_chat_stream(&self, body: Value) -> (fabro_http::StatusCode, Vec<String>) {
|
|
let response = self.post_chat(body).await;
|
|
let status = response.status();
|
|
let mut stream = response.bytes_stream();
|
|
let mut chunks = Vec::new();
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
chunks.push(
|
|
String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"),
|
|
);
|
|
}
|
|
|
|
(status, chunks)
|
|
}
|
|
|
|
pub async fn post_chat_with_auth_header(
|
|
&self,
|
|
body: Value,
|
|
authorization: Option<&str>,
|
|
) -> fabro_http::Response {
|
|
let mut request = self
|
|
.client
|
|
.post(format!("{}/v1/chat/completions", self.base_url));
|
|
|
|
if let Some(value) = authorization {
|
|
request = request.header(AUTHORIZATION, value);
|
|
}
|
|
|
|
request
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete")
|
|
}
|
|
|
|
pub async fn post_responses_stream_timed(&self, body: Value) -> TimedStreamResponse {
|
|
let started = Instant::now();
|
|
let response = self
|
|
.auth_client
|
|
.post(format!("{}/v1/responses", self.base_url))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.expect("request should complete");
|
|
let status = response.status();
|
|
let mut stream = response.bytes_stream();
|
|
let mut chunks = Vec::new();
|
|
let mut first_event_elapsed = Duration::ZERO;
|
|
|
|
if let Some(chunk) = stream.next().await {
|
|
chunks.push(
|
|
String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"),
|
|
);
|
|
first_event_elapsed = started.elapsed();
|
|
}
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
chunks.push(
|
|
String::from_utf8(chunk.expect("stream chunk").to_vec()).expect("utf8 stream"),
|
|
);
|
|
}
|
|
|
|
TimedStreamResponse {
|
|
status,
|
|
first_event_elapsed,
|
|
chunks,
|
|
}
|
|
}
|
|
|
|
pub async fn post_responses_stream_raw(&self, body: Value) -> RawStreamResponse {
|
|
self.raw_stream_request("/v1/responses", &body).await
|
|
}
|
|
|
|
pub async fn post_chat_stream_raw(&self, body: Value) -> RawStreamResponse {
|
|
self.raw_stream_request("/v1/chat/completions", &body).await
|
|
}
|
|
|
|
pub async fn enqueue_scenarios(&self, scenarios: Value) {
|
|
let response = self
|
|
.auth_client
|
|
.post(format!("{}/__admin/scenarios", self.base_url))
|
|
.json(&scenarios)
|
|
.send()
|
|
.await
|
|
.expect("admin request should complete");
|
|
|
|
assert_eq!(response.status(), 200);
|
|
}
|
|
|
|
pub async fn reset(&self) {
|
|
let response = self
|
|
.auth_client
|
|
.post(format!("{}/__admin/reset", self.base_url))
|
|
.send()
|
|
.await
|
|
.expect("admin reset should complete");
|
|
|
|
assert_eq!(response.status(), 200);
|
|
}
|
|
|
|
pub async fn request_logs(&self) -> Value {
|
|
self.auth_client
|
|
.get(format!("{}/__admin/requests", self.base_url))
|
|
.send()
|
|
.await
|
|
.expect("admin logs should complete")
|
|
.json()
|
|
.await
|
|
.expect("logs json should parse")
|
|
}
|
|
|
|
async fn raw_stream_request(&self, path: &str, body: &Value) -> RawStreamResponse {
|
|
let authority = self
|
|
.base_url
|
|
.strip_prefix("http://")
|
|
.expect("http base url");
|
|
let mut stream = TcpStream::connect(authority)
|
|
.await
|
|
.expect("socket should connect");
|
|
let body = serde_json::to_vec(body).expect("json body");
|
|
let authorization = self.authorization_header_value();
|
|
let request = format!(
|
|
"POST {path} HTTP/1.1\r\nHost: {authority}\r\nAuthorization: {authorization}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
|
|
stream
|
|
.write_all(request.as_bytes())
|
|
.await
|
|
.expect("request headers should write");
|
|
stream
|
|
.write_all(&body)
|
|
.await
|
|
.expect("request body should write");
|
|
stream.flush().await.expect("request should flush");
|
|
|
|
let mut response = Vec::new();
|
|
stream
|
|
.read_to_end(&mut response)
|
|
.await
|
|
.expect("response should read");
|
|
|
|
decode_http_response(&response)
|
|
}
|
|
}
|
|
|
|
pub fn parse_sse_transcript(body: &[u8]) -> Result<ParsedSseTranscript, String> {
|
|
let text = std::str::from_utf8(body).map_err(|_| "sse body was not valid utf-8".to_owned())?;
|
|
let mut blocks = Vec::new();
|
|
let mut events = Vec::new();
|
|
let mut done = false;
|
|
let mut remainder = text;
|
|
|
|
while let Some((block, rest)) = remainder.split_once("\n\n") {
|
|
if !block.is_empty() {
|
|
let event = parse_sse_block(block)?;
|
|
if event.data == "[DONE]" {
|
|
done = true;
|
|
}
|
|
blocks.push(block.to_owned());
|
|
events.push(event);
|
|
}
|
|
remainder = rest;
|
|
}
|
|
|
|
if !remainder.is_empty() {
|
|
return Err("sse stream ended with an incomplete event".to_owned());
|
|
}
|
|
|
|
Ok(ParsedSseTranscript {
|
|
blocks,
|
|
events,
|
|
done,
|
|
})
|
|
}
|
|
|
|
fn parse_sse_block(block: &str) -> Result<ParsedSseEvent, String> {
|
|
let mut event = None;
|
|
let mut data_lines = Vec::new();
|
|
|
|
for line in block.lines() {
|
|
if let Some(value) = line.strip_prefix("event: ") {
|
|
event = Some(value.to_owned());
|
|
continue;
|
|
}
|
|
if let Some(value) = line.strip_prefix("data: ") {
|
|
data_lines.push(value.to_owned());
|
|
continue;
|
|
}
|
|
if line.starts_with("id: ") || line.starts_with(':') {
|
|
continue;
|
|
}
|
|
|
|
return Err(format!("unsupported sse line: {line}"));
|
|
}
|
|
|
|
Ok(ParsedSseEvent {
|
|
event,
|
|
data: data_lines.join("\n"),
|
|
})
|
|
}
|
|
|
|
pub async fn record_response(response: fabro_http::Response) -> RecordedResponse {
|
|
let status = response.status();
|
|
let headers = response
|
|
.headers()
|
|
.iter()
|
|
.map(|(name, value)| {
|
|
(
|
|
name.as_str().to_ascii_lowercase(),
|
|
value.to_str().unwrap_or_default().to_owned(),
|
|
)
|
|
})
|
|
.collect();
|
|
let body = response
|
|
.bytes()
|
|
.await
|
|
.expect("response body should read")
|
|
.to_vec();
|
|
|
|
RecordedResponse {
|
|
status,
|
|
headers,
|
|
body,
|
|
}
|
|
}
|
|
|
|
fn decode_http_response(response: &[u8]) -> RawStreamResponse {
|
|
let header_end = response
|
|
.windows(4)
|
|
.position(|window| window == b"\r\n\r\n")
|
|
.expect("http header terminator")
|
|
+ 4;
|
|
let head = std::str::from_utf8(&response[..header_end]).expect("header utf8");
|
|
let mut lines = head.split("\r\n");
|
|
let status_line = lines.next().expect("status line");
|
|
let status = status_line
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.expect("status code")
|
|
.parse::<u16>()
|
|
.expect("numeric status");
|
|
let mut headers = HashMap::new();
|
|
|
|
for line in lines.filter(|line| !line.is_empty()) {
|
|
let (name, value) = line.split_once(':').expect("header format");
|
|
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned());
|
|
}
|
|
|
|
let body = if headers
|
|
.get("transfer-encoding")
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("chunked"))
|
|
{
|
|
decode_chunked_body(&response[header_end..])
|
|
} else {
|
|
response[header_end..].to_vec()
|
|
};
|
|
|
|
RawStreamResponse {
|
|
status,
|
|
headers,
|
|
body,
|
|
}
|
|
}
|
|
|
|
fn decode_chunked_body(encoded: &[u8]) -> Vec<u8> {
|
|
let mut cursor = 0;
|
|
let mut decoded = Vec::new();
|
|
|
|
loop {
|
|
let size_end = encoded[cursor..]
|
|
.windows(2)
|
|
.position(|window| window == b"\r\n")
|
|
.expect("chunk size terminator")
|
|
+ cursor;
|
|
let size = std::str::from_utf8(&encoded[cursor..size_end]).expect("chunk size utf8");
|
|
let size = usize::from_str_radix(size.trim(), 16).expect("hex chunk size");
|
|
cursor = size_end + 2;
|
|
|
|
if size == 0 {
|
|
break;
|
|
}
|
|
|
|
decoded.extend_from_slice(&encoded[cursor..cursor + size]);
|
|
cursor += size + 2;
|
|
}
|
|
|
|
decoded
|
|
}
|