mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-21 16:00:49 +00:00
Introduce `anyllm_client` crate containing HTTP client construction, SSRF-safe DNS resolver, retry/backoff logic, rate limit header parsing, and SSE frame parsing. These were previously inlined in the proxy crate. Rename crates from `anthropic_openai_proxy`/`anthropic_openai_translate` to `anyllm_proxy`/`anyllm_translate` throughout. proxy/backend: now re-exports retry, rate limit, and SSE symbols from the client crate; `send_with_retry` and `build_http_client` are thin adapters bridging BackendAuth/TlsConfig to the client crate's types. streaming: remove duplicate `find_double_newline` and `MAX_SSE_BUFFER_SIZE` definitions; import from `crate::backend` instead. Fix missing `pub mod` declarations in translator and proxy that were accidentally replaced by doc comments (streaming, usage_map, server, redact). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.0 KiB
Rust
163 lines
5.0 KiB
Rust
// Integration tests for multi-backend path-prefix routing.
|
|
|
|
use anyllm_proxy::config::MultiConfig;
|
|
use anyllm_proxy::server::routes;
|
|
use reqwest::Client;
|
|
|
|
fn test_multi_config() -> MultiConfig {
|
|
// Two backends: openai (default) and gemini
|
|
let toml = r#"
|
|
default_backend = "openai"
|
|
|
|
[backends.openai]
|
|
kind = "openai"
|
|
api_key = "sk-test-openai"
|
|
|
|
[backends.gemini]
|
|
kind = "gemini"
|
|
api_key = "test-gemini-key"
|
|
"#;
|
|
MultiConfig::from_toml_str(toml)
|
|
}
|
|
|
|
async fn spawn_multi_server() -> String {
|
|
// Enable open-relay mode for tests (no PROXY_API_KEYS configured).
|
|
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
|
let app = routes::app_multi(test_multi_config());
|
|
let listener = tokio::net::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}")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_works_with_multi_backend() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
let resp = client.get(format!("{base}/health")).send().await.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_route_requires_auth() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
// /v1/messages without auth should return 401
|
|
let resp = client
|
|
.post(format!("{base}/v1/messages"))
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"model":"claude-sonnet-4-6","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}"#)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prefixed_route_requires_auth() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
// /openai/v1/messages without auth should return 401
|
|
let resp = client
|
|
.post(format!("{base}/openai/v1/messages"))
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"model":"claude-sonnet-4-6","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}"#)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prefixed_models_endpoint_works() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
// /openai/v1/models should work with auth
|
|
let resp = client
|
|
.get(format!("{base}/openai/v1/models"))
|
|
.header("x-api-key", "any-key")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
assert!(body["data"].is_array());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gemini_prefixed_models_endpoint_works() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
let resp = client
|
|
.get(format!("{base}/gemini/v1/models"))
|
|
.header("x-api-key", "any-key")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_prefix_returns_404() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
let resp = client
|
|
.get(format!("{base}/unknown/v1/models"))
|
|
.header("x-api-key", "any-key")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 404);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metrics_shows_per_backend_breakdown() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
let resp = client
|
|
.get(format!("{base}/metrics"))
|
|
.header("x-api-key", "any-key")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
// Should have per-backend metrics
|
|
assert!(body["backends"]["openai"].is_object());
|
|
assert!(body["backends"]["gemini"].is_object());
|
|
// And totals
|
|
assert_eq!(body["total"]["requests_total"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_route_count_tokens_works() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
let resp = client
|
|
.post(format!("{base}/v1/messages/count_tokens"))
|
|
.header("x-api-key", "any-key")
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"model":"claude-sonnet-4-6","max_tokens":100,"messages":[{"role":"user","content":"hello world"}]}"#)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
assert!(body["input_tokens"].as_u64().unwrap() > 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prefixed_count_tokens_works() {
|
|
let base = spawn_multi_server().await;
|
|
let client = Client::new();
|
|
let resp = client
|
|
.post(format!("{base}/openai/v1/messages/count_tokens"))
|
|
.header("x-api-key", "any-key")
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"model":"claude-sonnet-4-6","max_tokens":100,"messages":[{"role":"user","content":"hello world"}]}"#)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
}
|