use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use audacity_sdk::{
Client, Config, ContentBlock, ConversationRole, ConverseStreamOutput, Error, Message,
};
async fn bind() -> (TcpListener, String) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, format!("http://127.0.0.1:{port}"))
}
fn http_response(status: u16, status_text: &str, headers_extra: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {len}\r\n{headers_extra}\r\n{body}",
len = body.len()
)
}
fn http_sse_response(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n{len:x}\r\n{body}\r\n0\r\n\r\n",
len = body.len()
)
}
async fn serve_once(listener: &TcpListener, response: String) {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let _ = sock.read(&mut buf).await;
sock.write_all(response.as_bytes()).await.unwrap();
}
fn client_for(base_url: &str) -> Client {
Client::new(
&Config::builder()
.api_key("audacity_api_test")
.base_url(base_url)
.max_retries(0)
.build()
.unwrap(),
)
}
fn user_message(text: &str) -> Message {
Message::builder()
.role(ConversationRole::User)
.content(ContentBlock::Text(text.into()))
.build()
.unwrap()
}
#[tokio::test]
async fn converse_happy_path() {
let (listener, base_url) = bind().await;
let body = r#"{"choices":[{"message":{"role":"assistant","content":"Hello world"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}"#;
let response = http_response(200, "OK", "", body);
let (result, _) = tokio::join!(
async {
let client = client_for(&base_url);
client
.converse()
.model_id("gpt-5.4-mini")
.messages(user_message("Hi"))
.send()
.await
},
serve_once(&listener, response),
);
let out = result.unwrap();
let msg = out.output().unwrap().as_message().unwrap();
assert_eq!(msg.content[0].as_text().unwrap(), "Hello world");
assert_eq!(*out.stop_reason(), audacity_sdk::StopReason::EndTurn);
assert_eq!(out.usage().input_tokens, 5);
assert_eq!(out.usage().output_tokens, 3);
assert_eq!(out.usage().total_tokens, 8);
assert!(out.metrics().latency_ms < 5000);
}
#[tokio::test]
async fn defensive_unwrap_enveloped_body() {
let (listener, base_url) = bind().await;
let body = r#"{"data":{"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{}}}"#;
let response = http_response(200, "OK", "", body);
let (result, _) = tokio::join!(
async {
client_for(&base_url)
.converse()
.model_id("m")
.messages(user_message("Q"))
.send()
.await
},
serve_once(&listener, response),
);
assert!(result.unwrap().output().is_some());
}
#[tokio::test]
async fn error_shape_a_access_denied() {
let (listener, base_url) = bind().await;
let body = r#"{"error":{"message":"Invalid API key","type":"authentication_error","code":"invalid_api_key"}}"#;
let response = http_response(401, "Unauthorized", "", body);
let (result, _) = tokio::join!(
async {
client_for(&base_url)
.converse()
.model_id("m")
.messages(user_message("Q"))
.send()
.await
},
serve_once(&listener, response),
);
assert!(matches!(result, Err(Error::AccessDenied(_))));
if let Err(Error::AccessDenied(d)) = result {
assert_eq!(d.error_code.as_deref(), Some("invalid_api_key"));
assert!(!d.is_retryable_variant());
}
}
trait IsRetryable {
fn is_retryable_variant(&self) -> bool;
}
impl IsRetryable for audacity_sdk::ErrorDetails {
fn is_retryable_variant(&self) -> bool {
false }
}
#[tokio::test]
async fn error_shape_b_model_not_allowed() {
let (listener, base_url) = bind().await;
let body = r#"{"success":false,"error":{"code":"MODEL_NOT_ALLOWED","message":"Not allowed","request_id":"req-abc","details":{}}}"#;
let response = http_response(403, "Forbidden", "", body);
let (result, _) = tokio::join!(
async {
client_for(&base_url)
.converse()
.model_id("m")
.messages(user_message("Q"))
.send()
.await
},
serve_once(&listener, response),
);
assert!(matches!(result, Err(Error::AccessDenied(_))));
if let Err(Error::AccessDenied(d)) = result {
assert_eq!(d.request_id.as_deref(), Some("req-abc"));
}
}
#[tokio::test]
async fn retry_after_429_then_success() {
let (listener, base_url) = bind().await;
let throttle_body = r#"{"error":{"message":"rate limited","code":"rate_limit_exceeded"}}"#;
let throttle_resp = http_response(
429,
"Too Many Requests",
"Retry-After: 0\r\n",
throttle_body,
);
let ok_body = r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{}}"#;
let ok_resp = http_response(200, "OK", "", ok_body);
let client = Client::new(
&Config::builder()
.api_key("audacity_api_test")
.base_url(&base_url)
.max_retries(1) .build()
.unwrap(),
);
let serve_two = async {
serve_once(&listener, throttle_resp).await;
serve_once(&listener, ok_resp).await;
};
let (result, _) = tokio::join!(
client
.converse()
.model_id("m")
.messages(user_message("Q"))
.send(),
serve_two,
);
assert!(result.is_ok(), "expected Ok but got: {:?}", result.err());
}
#[tokio::test]
async fn no_retry_on_401() {
let (listener, base_url) = bind().await;
let attempt_count = Arc::new(AtomicUsize::new(0));
let attempt_count2 = attempt_count.clone();
let body = r#"{"error":{"message":"unauth","code":"invalid_api_key"}}"#;
let client = Client::new(
&Config::builder()
.api_key("audacity_api_test")
.base_url(&base_url)
.max_retries(2)
.build()
.unwrap(),
);
let serve_task = async move {
serve_once(&listener, http_response(401, "Unauthorized", "", body)).await;
attempt_count2.fetch_add(1, Ordering::SeqCst);
};
let (result, _) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(
client
.converse()
.model_id("m")
.messages(user_message("Q"))
.send(),
serve_task,
)
})
.await
.unwrap();
assert!(matches!(result, Err(Error::AccessDenied(_))));
assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn no_retry_on_budget_exceeded() {
let (listener, base_url) = bind().await;
let body = r#"{"success":false,"error":{"code":"BUDGET_EXCEEDED","message":"over budget"}}"#;
let client = Client::new(
&Config::builder()
.api_key("audacity_api_test")
.base_url(&base_url)
.max_retries(2)
.build()
.unwrap(),
);
let serve_task = serve_once(&listener, http_response(429, "Too Many Requests", "", body));
let (result, _) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(
client
.converse()
.model_id("m")
.messages(user_message("Q"))
.send(),
serve_task,
)
})
.await
.unwrap();
assert!(matches!(result, Err(Error::ServiceQuotaExceeded(_))));
}
#[test]
fn missing_api_key_fails_fast() {
std::env::remove_var("AUDACITY_API_KEY");
let result = Config::builder().build();
assert!(matches!(result, Err(Error::MissingApiKey)));
}
#[tokio::test]
async fn user_agent_header_sent() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let base_url = format!("http://127.0.0.1:{port}");
let body = r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{}}"#;
let response = http_response(200, "OK", "", body);
let capture_ua = async {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap();
let req_str = String::from_utf8_lossy(&buf[..n]).to_string();
sock.write_all(response.as_bytes()).await.unwrap();
req_str
};
let (result, req_str) = tokio::join!(
async {
client_for(&base_url)
.converse()
.model_id("m")
.messages(user_message("Q"))
.send()
.await
},
capture_ua,
);
result.unwrap();
assert!(
req_str.contains("audacity-sdk-rust/0.1.0"),
"User-Agent not found in: {req_str}"
);
}
#[tokio::test]
async fn converse_stream_happy_path() {
let (listener, base_url) = bind().await;
let sse_body = concat!(
"data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"index\":0}]}\n\n",
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
"data: [DONE]\n\n",
);
let response = http_sse_response(sse_body);
let serve_task = serve_once(&listener, response);
let stream_task = async {
let mut out = client_for(&base_url)
.converse_stream()
.model_id("gpt-5.4-mini")
.messages(user_message("Hi"))
.send()
.await?;
let mut events: Vec<String> = Vec::new();
while let Some(event) = out.stream.recv().await? {
match &event {
ConverseStreamOutput::MessageStart(_) => events.push("MessageStart".into()),
ConverseStreamOutput::ContentBlockDelta(_) => {
events.push("ContentBlockDelta".into())
}
ConverseStreamOutput::ContentBlockStop(_) => events.push("ContentBlockStop".into()),
ConverseStreamOutput::MessageStop(_) => events.push("MessageStop".into()),
ConverseStreamOutput::Metadata(_) => events.push("Metadata".into()),
_ => {}
}
}
Ok::<_, Error>(events)
};
let (events, _) = tokio::join!(stream_task, serve_task);
let events = events.unwrap();
assert_eq!(
events,
vec![
"MessageStart",
"ContentBlockDelta",
"ContentBlockStop",
"MessageStop",
"Metadata",
]
);
}
#[tokio::test]
async fn stream_tool_call_emits_content_block_start() {
let (listener, base_url) = bind().await;
let sse_body = concat!(
"data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"NYC\\\"}\"}}]}}]}\n\n",
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{}}\n\n",
"data: [DONE]\n\n",
);
let response = http_sse_response(sse_body);
let stream_task = async {
let mut out = client_for(&base_url)
.converse_stream()
.model_id("m")
.messages(user_message("weather?"))
.send()
.await?;
let mut has_start = false;
let mut has_delta = false;
let mut stop_reason = None;
while let Some(event) = out.stream.recv().await? {
match event {
ConverseStreamOutput::ContentBlockStart(_) => has_start = true,
ConverseStreamOutput::ContentBlockDelta(_) => has_delta = true,
ConverseStreamOutput::MessageStop(e) => stop_reason = Some(e.stop_reason),
_ => {}
}
}
Ok::<_, Error>((has_start, has_delta, stop_reason))
};
let (result, _) = tokio::join!(stream_task, serve_once(&listener, response));
let (has_start, has_delta, stop_reason) = result.unwrap();
assert!(has_start, "expected ContentBlockStart for tool call");
assert!(has_delta, "expected ContentBlockDelta for tool arguments");
assert_eq!(stop_reason, Some(audacity_sdk::StopReason::ToolUse));
}