Files

270 lines
9.8 KiB
Rust

use anyllm_proxy::config::{self, BackendAuth, BackendKind, Config, ModelMapping, OpenAIApiFormat};
use anyllm_proxy::server::routes;
use axum::{
body::Body,
http::{header, Response},
response::IntoResponse,
routing::post,
Json, Router,
};
use reqwest::Client;
use serde_json::{json, Value};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::net::TcpListener;
fn openai_config_with_base(base_url: &str) -> Config {
Config {
backend: BackendKind::OpenAI,
openai_api_key: "test-key".to_string(),
openai_base_url: base_url.to_string(),
listen_port: 0,
model_mapping: ModelMapping {
big_model: "gpt-4o".into(),
small_model: "gpt-4o-mini".into(),
},
tls: config::TlsConfig::default(),
backend_auth: BackendAuth::BearerToken("test-key".into()),
log_bodies: false,
redact_secrets: false,
anthropic_thinking_repair: false,
pxpipe_compress: false,
expose_degradation_warnings: false,
openai_api_format: OpenAIApiFormat::Chat,
provider_id: None,
}
}
async fn spawn_proxy(config: Config) -> String {
std::env::set_var("PROXY_OPEN_RELAY", "true");
let app = routes::app(config);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
async fn spawn_counting_chat_backend() -> (String, Arc<AtomicUsize>) {
let hits = Arc::new(AtomicUsize::new(0));
let app = Router::new().route(
"/v1/chat/completions",
post({
let hits = hits.clone();
move |Json(body): Json<Value>| {
let hits = hits.clone();
async move {
let hit = hits.fetch_add(1, Ordering::SeqCst) + 1;
if body.get("stream").and_then(Value::as_bool) == Some(true) {
let sse = concat!(
"data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
);
return Response::builder()
.header(header::CONTENT_TYPE, "text/event-stream")
.body(Body::from(sse))
.unwrap();
}
Json(json!({
"id": format!("chatcmpl-cache-{hit}"),
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": format!("hit {hit}")},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6
}
}))
.into_response()
}
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), hits)
}
fn base_request() -> Value {
json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100
})
}
async fn post_chat(client: &Client, proxy_url: &str, body: Value) -> reqwest::Response {
client
.post(format!("{proxy_url}/v1/chat/completions"))
.header("x-api-key", "client-key")
.json(&body)
.send()
.await
.unwrap()
}
fn cache_header(response: &reqwest::Response) -> &str {
response
.headers()
.get("x-anyllm-cache")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
}
#[tokio::test]
async fn chat_completions_identical_request_misses_then_hits() {
let (backend_url, hits) = spawn_counting_chat_backend().await;
let proxy_url = spawn_proxy(openai_config_with_base(&backend_url)).await;
let client = Client::new();
let first = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(first.status(), 200);
assert_eq!(cache_header(&first), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 1);
let second = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(second.status(), 200);
assert_eq!(cache_header(&second), "hit");
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn chat_completions_no_cache_skips_lookup_but_stores() {
let (backend_url, hits) = spawn_counting_chat_backend().await;
let proxy_url = spawn_proxy(openai_config_with_base(&backend_url)).await;
let client = Client::new();
let first = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(first.status(), 200);
assert_eq!(cache_header(&first), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 1);
let mut refresh = base_request();
refresh["cache"] = json!({"no-cache": true});
let second = post_chat(&client, &proxy_url, refresh).await;
assert_eq!(second.status(), 200);
assert_eq!(cache_header(&second), "bypass");
assert_eq!(hits.load(Ordering::SeqCst), 2);
let third = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(third.status(), 200);
assert_eq!(cache_header(&third), "hit");
assert_eq!(hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn chat_completions_no_store_does_not_populate_on_miss() {
let (backend_url, hits) = spawn_counting_chat_backend().await;
let proxy_url = spawn_proxy(openai_config_with_base(&backend_url)).await;
let client = Client::new();
let mut no_store = base_request();
no_store["cache"] = json!({"no-store": true});
let first = post_chat(&client, &proxy_url, no_store).await;
assert_eq!(first.status(), 200);
assert_eq!(cache_header(&first), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 1);
let second = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(second.status(), 200);
assert_eq!(cache_header(&second), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 2);
let third = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(third.status(), 200);
assert_eq!(cache_header(&third), "hit");
assert_eq!(hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn chat_completions_cache_ttl_does_not_fragment_key() {
let (backend_url, hits) = spawn_counting_chat_backend().await;
let proxy_url = spawn_proxy(openai_config_with_base(&backend_url)).await;
let client = Client::new();
let mut ttl_60 = base_request();
ttl_60["cache"] = json!({"ttl": 60});
let first = post_chat(&client, &proxy_url, ttl_60).await;
assert_eq!(first.status(), 200);
assert_eq!(cache_header(&first), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 1);
let mut ttl_3600 = base_request();
ttl_3600["cache"] = json!({"ttl": 3600});
let second = post_chat(&client, &proxy_url, ttl_3600).await;
assert_eq!(second.status(), 200);
assert_eq!(cache_header(&second), "hit");
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cached_multi_tool_request_does_not_mask_parallel_tool_calls_variant() {
let (backend_url, hits) = spawn_counting_chat_backend().await;
let proxy_url = spawn_proxy(openai_config_with_base(&backend_url)).await;
let client = Client::new();
let tools = json!([
{
"type": "function",
"function": {
"name": "lookup_city",
"description": "lookup city",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "lookup_weather",
"description": "lookup weather",
"parameters": {"type": "object", "properties": {}}
}
}
]);
let mut valid = base_request();
valid["tools"] = tools.clone();
let first = post_chat(&client, &proxy_url, valid).await;
assert_eq!(first.status(), 200);
assert_eq!(cache_header(&first), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 1);
let mut variant = base_request();
variant["tools"] = tools;
variant["parallel_tool_calls"] = json!(false);
let second = post_chat(&client, &proxy_url, variant).await;
assert_eq!(second.status(), 200);
assert_eq!(cache_header(&second), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn streaming_request_bypasses_and_does_not_populate_cache() {
let (backend_url, hits) = spawn_counting_chat_backend().await;
let proxy_url = spawn_proxy(openai_config_with_base(&backend_url)).await;
let client = Client::new();
let mut stream = base_request();
stream["stream"] = json!(true);
let first = post_chat(&client, &proxy_url, stream).await;
assert_eq!(first.status(), 200);
assert_eq!(cache_header(&first), "bypass");
let _ = first.text().await.unwrap();
assert_eq!(hits.load(Ordering::SeqCst), 1);
let second = post_chat(&client, &proxy_url, base_request()).await;
assert_eq!(second.status(), 200);
assert_eq!(cache_header(&second), "miss");
assert_eq!(hits.load(Ordering::SeqCst), 2);
}