chore: vendor sglang v0.5.10 snapshot

This commit is contained in:
2026-04-24 12:29:36 +00:00
parent 78f0d15221
commit bded08301f
4308 changed files with 1200894 additions and 2 deletions

View File

@@ -0,0 +1,169 @@
use std::{collections::HashMap, sync::Arc};
use smg::{
core::{BasicWorkerBuilder, Worker, WorkerType},
policies::{CacheAwareConfig, CacheAwarePolicy, LoadBalancingPolicy, SelectWorkerInfo},
};
#[tokio::test]
async fn test_backward_compatibility_with_empty_model_id() {
let config = CacheAwareConfig {
cache_threshold: 0.5,
balance_abs_threshold: 2,
balance_rel_threshold: 1.5,
eviction_interval_secs: 0, // Disable background eviction for testing
max_tree_size: 100,
};
let policy = CacheAwarePolicy::with_config(config);
// Create workers with empty model_id (simulating existing routers)
let worker1 = BasicWorkerBuilder::new("http://worker1:8080")
.worker_type(WorkerType::Regular)
.api_key("test_api_key")
.build();
// No model_id label - should default to "unknown"
let mut labels2 = HashMap::new();
labels2.insert("model_id".to_string(), "unknown".to_string());
let worker2 = BasicWorkerBuilder::new("http://worker2:8080")
.worker_type(WorkerType::Regular)
.api_key("test_api_key")
.labels(labels2)
.build();
// Add workers - should both go to "default" tree
policy.add_worker(&worker1);
policy.add_worker(&worker2);
// Create worker list
let workers: Vec<Arc<dyn Worker>> = vec![Arc::new(worker1.clone()), Arc::new(worker2.clone())];
// Select worker - should work without errors
let selected = policy
.select_worker(
&workers,
&SelectWorkerInfo {
request_text: Some("test request"),
..Default::default()
},
)
.await;
assert!(selected.is_some(), "Should select a worker");
// Remove workers - should work without errors
policy.remove_worker(&worker1);
policy.remove_worker(&worker2);
}
#[tokio::test]
async fn test_mixed_model_ids() {
let config = CacheAwareConfig {
cache_threshold: 0.5,
balance_abs_threshold: 2,
balance_rel_threshold: 1.5,
eviction_interval_secs: 0,
max_tree_size: 100,
};
let policy = CacheAwarePolicy::with_config(config);
// Create workers with different model_id scenarios
let worker1 = BasicWorkerBuilder::new("http://worker1:8080")
.worker_type(WorkerType::Regular)
.api_key("test_api_key")
.build();
// No model_id label - defaults to "unknown" which goes to "default" tree
let mut labels2 = HashMap::new();
labels2.insert("model_id".to_string(), "llama-3".to_string());
let worker2 = BasicWorkerBuilder::new("http://worker2:8080")
.worker_type(WorkerType::Regular)
.labels(labels2)
.api_key("test_api_key")
.build();
let mut labels3 = HashMap::new();
labels3.insert("model_id".to_string(), "unknown".to_string());
let worker3 = BasicWorkerBuilder::new("http://worker3:8080")
.worker_type(WorkerType::Regular)
.labels(labels3)
.build();
let mut labels4 = HashMap::new();
labels4.insert("model_id".to_string(), "llama-3".to_string());
let worker4 = BasicWorkerBuilder::new("http://worker4:8080")
.worker_type(WorkerType::Regular)
.labels(labels4)
.build();
// Add all workers
policy.add_worker(&worker1);
policy.add_worker(&worker2);
policy.add_worker(&worker3);
policy.add_worker(&worker4);
let default_workers: Vec<Arc<dyn Worker>> =
vec![Arc::new(worker1.clone()), Arc::new(worker3.clone())];
let info = SelectWorkerInfo {
request_text: Some("test request"),
..Default::default()
};
let selected = policy.select_worker(&default_workers, &info).await;
assert!(selected.is_some(), "Should select from default workers");
let llama_workers: Vec<Arc<dyn Worker>> =
vec![Arc::new(worker2.clone()), Arc::new(worker4.clone())];
let selected = policy.select_worker(&llama_workers, &info).await;
assert!(selected.is_some(), "Should select from llama-3 workers");
let all_workers: Vec<Arc<dyn Worker>> = vec![
Arc::new(worker1.clone()),
Arc::new(worker2.clone()),
Arc::new(worker3.clone()),
Arc::new(worker4.clone()),
];
let selected = policy.select_worker(&all_workers, &info).await;
assert!(selected.is_some(), "Should select from all workers");
}
#[tokio::test]
async fn test_remove_worker_by_url_backward_compat() {
let config = CacheAwareConfig::default();
let policy = CacheAwarePolicy::with_config(config);
// Create workers with different model_ids
let mut labels1 = HashMap::new();
labels1.insert("model_id".to_string(), "llama-3".to_string());
let worker1 = BasicWorkerBuilder::new("http://worker1:8080")
.worker_type(WorkerType::Regular)
.labels(labels1)
.api_key("test_api_key")
.build();
let worker2 = BasicWorkerBuilder::new("http://worker2:8080")
.worker_type(WorkerType::Regular)
.api_key("test_api_key")
.build();
// No model_id label - defaults to "unknown"
// Add workers
policy.add_worker(&worker1);
policy.add_worker(&worker2);
// Remove by URL (backward compatibility method)
// Should remove from all trees since we don't know the model
policy.remove_worker_by_url("http://worker1:8080");
let workers: Vec<Arc<dyn Worker>> = vec![Arc::new(worker2.clone())];
let selected = policy
.select_worker(
&workers,
&SelectWorkerInfo {
request_text: Some("test"),
..Default::default()
},
)
.await;
assert_eq!(selected, Some(0), "Should only have worker2 left");
}

View File

@@ -0,0 +1,308 @@
//! Header forwarding integration tests
//!
//! Tests for header propagation through the router to workers.
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use smg::config::RouterConfig;
use tower::ServiceExt;
use crate::common::{
mock_worker::{HealthStatus, MockWorkerConfig, WorkerType},
AppTestContext,
};
#[cfg(test)]
mod header_forwarding_tests {
use super::*;
/// Test that X-Request-Id header is forwarded
#[tokio::test]
async fn test_request_id_forwarding() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
port: 19400,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}])
.await;
let app = ctx.create_app().await;
let custom_request_id = "test-request-id-12345";
let payload = json!({
"text": "Test header forwarding",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header("x-request-id", custom_request_id)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Response should have the same request ID
let response_id = resp.headers().get("x-request-id");
assert!(response_id.is_some(), "Response should have x-request-id");
assert_eq!(
response_id.unwrap().to_str().unwrap(),
custom_request_id,
"Request ID should be preserved"
);
ctx.shutdown().await;
}
/// Test custom request ID headers
#[tokio::test]
async fn test_custom_request_id_headers() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.random_policy()
.host("127.0.0.1")
.port(3500)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.request_id_headers(vec![
"custom-trace-id".to_string(),
"x-correlation-id".to_string(),
])
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 19401,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
let custom_trace_id = "my-custom-trace-123";
let payload = json!({
"text": "Test custom headers",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header("custom-trace-id", custom_trace_id)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Response should use the custom header as request ID
let response_id = resp.headers().get("x-request-id");
assert!(response_id.is_some());
assert_eq!(
response_id.unwrap().to_str().unwrap(),
custom_trace_id,
"Custom header should be used as request ID"
);
ctx.shutdown().await;
}
/// Test correlation ID header forwarding
#[tokio::test]
async fn test_correlation_id_forwarding() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
port: 19402,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}])
.await;
let app = ctx.create_app().await;
let correlation_id = "correlation-abc-789";
let payload = json!({
"text": "Test correlation ID",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header("x-correlation-id", correlation_id)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Response should preserve the correlation ID as request ID
let response_id = resp.headers().get("x-request-id");
assert!(response_id.is_some());
assert_eq!(
response_id.unwrap().to_str().unwrap(),
correlation_id,
"Correlation ID should be preserved"
);
ctx.shutdown().await;
}
/// Test that request ID is generated when not provided
#[tokio::test]
async fn test_auto_generated_request_id() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
port: 19403,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}])
.await;
let app = ctx.create_app().await;
let payload = json!({
"text": "Test auto-generated ID",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Response should have an auto-generated request ID
let response_id = resp.headers().get("x-request-id");
assert!(
response_id.is_some(),
"Response should have auto-generated x-request-id"
);
let id_value = response_id.unwrap().to_str().unwrap();
assert!(!id_value.is_empty(), "Request ID should not be empty");
// For generate endpoint, ID should have 'gnt-' prefix
assert!(
id_value.starts_with("gnt-"),
"Generate endpoint should have gnt- prefix, got: {}",
id_value
);
ctx.shutdown().await;
}
/// Test chat completions request ID format
#[tokio::test]
async fn test_chat_completions_request_id_format() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
port: 19404,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "test-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Response should have chatcmpl- prefix for chat completions
let response_id = resp.headers().get("x-request-id");
assert!(response_id.is_some());
let id_value = response_id.unwrap().to_str().unwrap();
assert!(
id_value.starts_with("chatcmpl-"),
"Chat completions should have chatcmpl- prefix, got: {}",
id_value
);
ctx.shutdown().await;
}
/// Test multiple header priorities
#[tokio::test]
async fn test_header_priority() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
port: 19405,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}])
.await;
let app = ctx.create_app().await;
let primary_id = "primary-request-id";
let fallback_id = "fallback-correlation-id";
let payload = json!({
"text": "Test header priority",
"stream": false
});
// When x-request-id is provided, it should take priority
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header("x-request-id", primary_id)
.header("x-correlation-id", fallback_id)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let response_id = resp.headers().get("x-request-id");
assert!(response_id.is_some());
assert_eq!(
response_id.unwrap().to_str().unwrap(),
primary_id,
"x-request-id should take priority"
);
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,373 @@
//! Load balancing integration tests
//!
//! Tests for various load balancing policies: round_robin, random, cache_aware, etc.
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use smg::config::{CircuitBreakerConfig, RetryConfig};
use tower::ServiceExt;
use crate::common::{AppTestContext, TestRouterConfig, TestWorkerConfig};
#[cfg(test)]
mod round_robin_tests {
use super::*;
/// Test that round robin distributes requests evenly across workers
#[tokio::test]
async fn test_round_robin_distribution() {
let config = TestRouterConfig::round_robin(3100);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19001, 3))
.await;
let app = ctx.create_app().await;
let num_requests = 30;
let mut success_count = 0;
for i in 0..num_requests {
let payload = json!({
"text": format!("Request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
// All requests should succeed with round robin across 3 healthy workers
assert_eq!(
success_count, num_requests,
"All requests should succeed with round robin"
);
ctx.shutdown().await;
}
/// Test round robin with one worker failing
#[tokio::test]
async fn test_round_robin_with_failing_worker() {
let config = TestRouterConfig::round_robin_with_retry(
3101,
RetryConfig {
max_retries: 2,
initial_backoff_ms: 10,
max_backoff_ms: 50,
..Default::default()
},
);
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::flaky(19004, 1.0), // Always fail
TestWorkerConfig::healthy(19005), // Always succeed
],
)
.await;
let app = ctx.create_app().await;
// With retries enabled, requests should eventually succeed
// by being retried on the healthy worker
let payload = json!({
"text": "Test with retries",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Request should succeed via retry on healthy worker"
);
ctx.shutdown().await;
}
}
#[cfg(test)]
mod random_tests {
use super::*;
/// Test that random policy distributes requests across workers
#[tokio::test]
async fn test_random_distribution() {
let config = TestRouterConfig::random(3102);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19010, 2))
.await;
let app = ctx.create_app().await;
let num_requests = 20;
let mut success_count = 0;
for i in 0..num_requests {
let payload = json!({
"text": format!("Random request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
// All requests should succeed with random policy
assert_eq!(
success_count, num_requests,
"All requests should succeed with random policy"
);
ctx.shutdown().await;
}
}
#[cfg(test)]
mod cache_aware_tests {
use super::*;
/// Test cache-aware routing uses consistent hashing
#[tokio::test]
async fn test_cache_aware_consistent_routing() {
let config = TestRouterConfig::cache_aware(3103);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19020, 2))
.await;
let app = ctx.create_app().await;
// Same prompt should route to same worker (consistent hashing)
let same_prompt = "Hello, cache-aware routing test!";
let mut worker_ids: Vec<Option<String>> = Vec::new();
for _ in 0..5 {
let payload = json!({
"text": same_prompt,
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Extract worker ID from response header if available
let worker_id = resp
.headers()
.get("x-worker-id")
.map(|v| v.to_str().unwrap().to_string());
worker_ids.push(worker_id);
}
// All requests should succeed
assert_eq!(worker_ids.len(), 5);
ctx.shutdown().await;
}
/// Test cache-aware routing with different prompts
#[tokio::test]
async fn test_cache_aware_different_prompts() {
let config = TestRouterConfig::cache_aware(3104);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19022, 2))
.await;
let app = ctx.create_app().await;
let prompts = vec![
"First unique prompt",
"Second unique prompt",
"Third unique prompt",
"Fourth unique prompt",
];
let mut success_count = 0;
for prompt in prompts {
let payload = json!({
"text": prompt,
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
// All requests should succeed
assert_eq!(success_count, 4);
ctx.shutdown().await;
}
}
#[cfg(test)]
mod worker_health_tests {
use super::*;
/// Test that failing workers are avoided via circuit breaker/retry
#[tokio::test]
async fn test_skip_failing_workers() {
let config = TestRouterConfig::round_robin_with_reliability(
3105,
RetryConfig {
max_retries: 3,
initial_backoff_ms: 10,
max_backoff_ms: 50,
..Default::default()
},
CircuitBreakerConfig {
failure_threshold: 2,
success_threshold: 1,
timeout_duration_secs: 2,
window_duration_secs: 10,
},
);
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::flaky(19030, 1.0), // Always fails
TestWorkerConfig::healthy(19031), // Always succeeds
],
)
.await;
let app = ctx.create_app().await;
// Requests should succeed by retrying on healthy worker
// or by circuit breaker opening on failing worker
for i in 0..10 {
let payload = json!({
"text": format!("Request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
ctx.shutdown().await;
}
/// Test behavior when all workers are unhealthy
#[tokio::test]
async fn test_all_workers_unhealthy() {
let ctx = AppTestContext::new(vec![]).await;
let app = ctx.create_app().await;
let payload = json!({
"text": "Test with no workers",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
// Should return service unavailable when no workers available
assert!(
resp.status() == StatusCode::SERVICE_UNAVAILABLE
|| resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
"Expected 503 or 500 when no workers available, got {}",
resp.status()
);
ctx.shutdown().await;
}
}
#[cfg(test)]
mod worker_response_delay_tests {
use super::*;
/// Test that response delays are handled correctly
#[tokio::test]
async fn test_worker_with_delay() {
let config = TestRouterConfig::random(3106);
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::slow(19040, 100)], // 100ms delay
)
.await;
let app = ctx.create_app().await;
let start = std::time::Instant::now();
let payload = json!({
"text": "Test with delay",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let elapsed = start.elapsed();
assert_eq!(resp.status(), StatusCode::OK);
// Response should take at least 100ms due to configured delay
assert!(
elapsed.as_millis() >= 100,
"Response should be delayed by at least 100ms, got {}ms",
elapsed.as_millis()
);
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,346 @@
//! Manual routing policy integration tests
//!
//! Tests for the manual routing policy with sticky sessions using X-SMG-Routing-Key header.
use std::collections::{HashMap, HashSet};
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use tower::ServiceExt;
use crate::common::{AppTestContext, TestRouterConfig, TestWorkerConfig};
const ROUTING_KEY_HEADER: &str = "X-SMG-Routing-Key";
#[cfg(test)]
mod manual_routing_tests {
use super::*;
/// Test sticky routing with X-SMG-Routing-Key header
#[tokio::test]
async fn test_manual_routing_with_header() {
let config = TestRouterConfig::manual(3700);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19700, 2))
.await;
let app = ctx.create_app().await;
// Send requests with different routing keys
let mut key_workers: HashMap<String, HashSet<String>> = HashMap::new();
for key_id in 0..5 {
let routing_key = format!("user-{}", key_id);
for _ in 0..4 {
let payload = json!({
"text": format!("Request for {}", routing_key),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header(ROUTING_KEY_HEADER, &routing_key)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Extract worker ID from response header
if let Some(worker_id) = resp.headers().get("x-worker-id") {
let worker = worker_id.to_str().unwrap().to_string();
key_workers
.entry(routing_key.clone())
.or_default()
.insert(worker);
}
}
}
// Verify sticky: each routing key should route to exactly one worker
for (key, workers) in &key_workers {
assert_eq!(
workers.len(),
1,
"Routing key {} should route to exactly one worker, got {:?}",
key,
workers
);
}
ctx.shutdown().await;
}
/// Test random fallback when no routing key header is provided
#[tokio::test]
async fn test_manual_routing_without_header() {
let config = TestRouterConfig::manual(3701);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19702, 2))
.await;
let app = ctx.create_app().await;
let mut success_count = 0;
// Send requests without routing key - should fall back to random selection
for i in 0..20 {
let payload = json!({
"text": format!("Request without key {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
// No ROUTING_KEY_HEADER
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
// All requests should succeed via random fallback
assert_eq!(
success_count, 20,
"All requests should succeed with random fallback when no routing key provided"
);
ctx.shutdown().await;
}
/// Test that same routing key consistently routes to same worker
#[tokio::test]
async fn test_manual_routing_consistency() {
let config = TestRouterConfig::manual(3702);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19704, 3))
.await;
let app = ctx.create_app().await;
let routing_key = "consistent-user-123";
let mut seen_workers: Vec<String> = Vec::new();
// Send multiple requests with same routing key
for i in 0..10 {
let payload = json!({
"text": format!("Consistent request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header(ROUTING_KEY_HEADER, routing_key)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
if let Some(worker_id) = resp.headers().get("x-worker-id") {
seen_workers.push(worker_id.to_str().unwrap().to_string());
}
}
// All requests should go to the same worker
if !seen_workers.is_empty() {
let first_worker = &seen_workers[0];
for worker in &seen_workers {
assert_eq!(
worker, first_worker,
"All requests with same routing key should go to same worker"
);
}
}
ctx.shutdown().await;
}
}
#[cfg(test)]
mod manual_min_group_tests {
use super::*;
async fn send_request(app: axum::Router, routing_key: &str) -> (String, String) {
let payload = json!({
"text": format!("Request for {}", routing_key),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.header(ROUTING_KEY_HEADER, routing_key)
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let worker_id = resp
.headers()
.get("x-worker-id")
.expect("Response should have x-worker-id header")
.to_str()
.unwrap()
.to_string();
(routing_key.to_string(), worker_id)
}
#[tokio::test]
async fn test_min_group_concurrent_distribution() {
let config = TestRouterConfig::manual_min_group(3910);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::slow_workers(29910, 3, 500))
.await;
let app = ctx.create_app().await;
let mut handles = Vec::new();
for i in 0..9 {
let routing_key = format!("key-{}", i);
let app_clone = app.clone();
let handle = tokio::spawn(async move { send_request(app_clone, &routing_key).await });
handles.push(handle);
}
let results: Vec<(String, String)> = futures_util::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let key_to_worker: HashMap<String, String> = results.into_iter().collect();
let worker_counts: HashMap<String, usize> =
key_to_worker.values().fold(HashMap::new(), |mut acc, w| {
*acc.entry(w.clone()).or_default() += 1;
acc
});
assert_eq!(
worker_counts.len(),
3,
"min_group should distribute keys across all 3 workers, got {:?}",
worker_counts
);
for (worker, count) in &worker_counts {
assert_eq!(
*count, 3,
"Worker {} should have exactly 3 keys, got {}. Distribution: {:?}",
worker, count, key_to_worker
);
}
ctx.shutdown().await;
}
#[tokio::test]
async fn test_min_group_sticky_routing() {
let config = TestRouterConfig::manual_min_group(3911);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::slow_workers(29920, 3, 200))
.await;
let app = ctx.create_app().await;
let routing_key = "sticky-key-123";
let mut handles = Vec::new();
for _ in 0..5 {
let app_clone = app.clone();
let key = routing_key.to_string();
let handle = tokio::spawn(async move { send_request(app_clone, &key).await });
handles.push(handle);
}
let results: Vec<(String, String)> = futures_util::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let workers: Vec<String> = results.into_iter().map(|(_, w)| w).collect();
let unique_workers: HashSet<&String> = workers.iter().collect();
assert_eq!(
unique_workers.len(),
1,
"All requests with same routing key should route to same worker, got {:?}",
unique_workers
);
ctx.shutdown().await;
}
#[tokio::test]
async fn test_min_group_mixed_concurrent_routing() {
let config = TestRouterConfig::manual_min_group(3912);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::slow_workers(29930, 2, 300))
.await;
let app = ctx.create_app().await;
let mut handles = Vec::new();
for i in 0..4 {
let routing_key = format!("key-{}", i);
for _ in 0..3 {
let app_clone = app.clone();
let key = routing_key.clone();
let handle = tokio::spawn(async move { send_request(app_clone, &key).await });
handles.push(handle);
}
}
let results: Vec<(String, String)> = futures_util::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let mut key_to_workers: HashMap<String, HashSet<String>> = HashMap::new();
for (key, worker) in results {
key_to_workers.entry(key).or_default().insert(worker);
}
for (key, workers) in &key_to_workers {
assert_eq!(
workers.len(),
1,
"Key {} should route to exactly one worker (sticky), but got {:?}",
key,
workers
);
}
let all_workers: HashSet<String> = key_to_workers.values().flatten().cloned().collect();
assert_eq!(
all_workers.len(),
2,
"Keys should be distributed across both workers"
);
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,14 @@
//! Routing integration tests
pub mod cache_aware_backward_compat_test;
pub mod header_forwarding_test;
pub mod load_balancing_test;
pub mod manual_routing_test;
pub mod payload_size_test;
pub mod pd_routing_test;
pub mod policy_registry_integration;
pub mod power_of_two_test;
pub mod service_discovery_test;
pub mod test_openai_routing;
pub mod test_pd_routing;
pub mod worker_management_test;

View File

@@ -0,0 +1,297 @@
//! Payload size integration tests
//!
//! Tests for request payload size limits and handling.
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use smg::config::RouterConfig;
use tower::ServiceExt;
use crate::common::{
mock_worker::{HealthStatus, MockWorkerConfig, WorkerType},
AppTestContext,
};
#[cfg(test)]
mod payload_size_tests {
use super::*;
/// Test that small payloads are handled correctly
#[tokio::test]
async fn test_small_payload() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4200)
.max_payload_size(1024 * 1024) // 1MB limit
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20200,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
let payload = json!({
"text": "Small payload test",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Small payload should be accepted"
);
ctx.shutdown().await;
}
/// Test that payloads within limit are accepted
#[tokio::test]
async fn test_payload_within_limit() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4201)
.max_payload_size(1024 * 1024) // 1MB limit
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20201,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Create a ~100KB payload (well within 1MB limit)
let large_text = "x".repeat(100 * 1024);
let payload = json!({
"text": large_text,
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Payload within limit should be accepted"
);
ctx.shutdown().await;
}
/// Test that payloads exceeding limit are rejected
#[tokio::test]
async fn test_payload_exceeds_limit() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4202)
.max_payload_size(1024) // Very small 1KB limit
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20202,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Create a payload larger than 1KB limit
let large_text = "x".repeat(2048);
let payload = json!({
"text": large_text,
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
// Should be rejected with 413 Payload Too Large or similar
assert!(
resp.status() == StatusCode::PAYLOAD_TOO_LARGE
|| resp.status() == StatusCode::BAD_REQUEST,
"Payload exceeding limit should be rejected, got {}",
resp.status()
);
ctx.shutdown().await;
}
/// Test edge case: payload exactly at limit
#[tokio::test]
async fn test_payload_at_exact_limit() {
// Use a more reasonable limit for this test
let limit_bytes = 10 * 1024; // 10KB limit
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4203)
.max_payload_size(limit_bytes)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20203,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Create a payload slightly under the limit (accounting for JSON overhead)
let text_size = limit_bytes - 100; // Leave room for JSON structure
let text = "x".repeat(text_size);
let payload = json!({
"text": text,
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
// Payload at/near limit should be accepted
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::PAYLOAD_TOO_LARGE,
"Payload at limit boundary, got status {}",
resp.status()
);
ctx.shutdown().await;
}
/// Test default payload size limit (256MB)
#[tokio::test]
async fn test_default_payload_limit() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4204)
.max_payload_size(256 * 1024 * 1024) // Default 256MB
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20204,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Create a 1MB payload (well within 256MB)
let large_text = "x".repeat(1024 * 1024);
let payload = json!({
"text": large_text,
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"1MB payload should be accepted with 256MB limit"
);
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,222 @@
//! Prefill/Decode (PD) routing integration tests
//!
//! Tests for prefill-decode disaggregation routing mode.
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use smg::config::RouterConfig;
use tower::ServiceExt;
use crate::common::{
mock_worker::{HealthStatus, MockWorkerConfig, WorkerType},
AppTestContext, TestWorkerConfig,
};
#[cfg(test)]
mod pd_routing_tests {
use super::*;
/// Test basic PD mode routing with prefill and decode workers
#[tokio::test]
async fn test_pd_mode_basic_routing() {
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![
("http://127.0.0.1:19800".to_string(), None),
("http://127.0.0.1:19801".to_string(), None),
],
vec![
"http://127.0.0.1:19802".to_string(),
"http://127.0.0.1:19803".to_string(),
],
)
.power_of_two_policy(1)
.host("127.0.0.1")
.port(3800)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
// Note: For PD mode tests, we need to start prefill and decode workers separately
// The test context will need to handle this specially
let ctx = AppTestContext::new_with_config(
config,
vec![
// Prefill workers
TestWorkerConfig::prefill(19800),
TestWorkerConfig::prefill(19801),
// Decode workers
TestWorkerConfig::decode(19802),
TestWorkerConfig::decode(19803),
],
)
.await;
let app = ctx.create_app().await;
// Send requests and verify they succeed
for i in 0..10 {
let payload = json!({
"text": format!("PD mode request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"PD mode request should succeed"
);
}
ctx.shutdown().await;
}
/// Test PD mode with round robin policy
#[tokio::test]
async fn test_pd_mode_round_robin() {
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![("http://127.0.0.1:19810".to_string(), None)],
vec![
"http://127.0.0.1:19811".to_string(),
"http://127.0.0.1:19812".to_string(),
],
)
.round_robin_policy()
.host("127.0.0.1")
.port(3801)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::prefill(19810),
TestWorkerConfig::decode(19811),
TestWorkerConfig::decode(19812),
],
)
.await;
let app = ctx.create_app().await;
let mut success_count = 0;
for i in 0..20 {
let payload = json!({
"text": format!("PD round robin {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
assert_eq!(
success_count, 20,
"All requests should succeed in PD mode with round robin"
);
ctx.shutdown().await;
}
/// Test PD mode handles worker failures gracefully
#[tokio::test]
async fn test_pd_mode_with_failing_decode_worker() {
use smg::config::RetryConfig;
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![("http://127.0.0.1:19820".to_string(), None)],
vec![
"http://127.0.0.1:19821".to_string(),
"http://127.0.0.1:19822".to_string(),
],
)
.round_robin_policy()
.host("127.0.0.1")
.port(3802)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.retry_config(RetryConfig {
max_retries: 3,
initial_backoff_ms: 10,
max_backoff_ms: 50,
..Default::default()
})
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::prefill(19820),
MockWorkerConfig {
port: 19821,
worker_type: WorkerType::Decode,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 1.0, // Failing decode worker
},
TestWorkerConfig::decode(19822), // Healthy decode worker
],
)
.await;
let app = ctx.create_app().await;
// Request should succeed via retry to healthy decode worker
let payload = json!({
"text": "Test with failing decode worker",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Request should succeed via retry to healthy decode worker"
);
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,173 @@
//! Integration tests for PolicyRegistry with RouterManager
use std::{collections::HashMap, sync::Arc};
use smg::{
config::PolicyConfig, core::WorkerRegistry, policies::PolicyRegistry,
protocols::worker_spec::WorkerConfigRequest, routers::router_manager::RouterManager,
};
#[tokio::test]
async fn test_policy_registry_with_router_manager() {
// Create HTTP client
let _client = reqwest::Client::new();
// Create shared registries
let worker_registry = Arc::new(WorkerRegistry::new());
let policy_registry = Arc::new(PolicyRegistry::new(PolicyConfig::RoundRobin));
// Create RouterManager with shared registries
let _router_manager = RouterManager::new(worker_registry.clone());
// Add first worker for llama-3 with cache_aware policy hint
let mut labels1 = HashMap::new();
labels1.insert("policy".to_string(), "cache_aware".to_string());
let _worker1_config = WorkerConfigRequest {
url: "http://worker1:8000".to_string(),
model_id: Some("llama-3".to_string()),
api_key: Some("test_api_key".to_string()),
worker_type: None,
priority: None,
cost: None,
labels: labels1,
bootstrap_port: None,
tokenizer_path: None,
reasoning_parser: None,
tool_parser: None,
chat_template: None,
runtime: None,
health_check_timeout_secs: 30,
health_check_interval_secs: 60,
health_success_threshold: 2,
health_failure_threshold: 3,
disable_health_check: false,
max_connection_attempts: 20,
dp_aware: false,
};
// This would normally connect to a real worker, but for testing we'll just verify the structure
// In a real test, we'd need to mock the worker or use a test server
let _llama_policy = policy_registry.get_policy("llama-3");
// After first worker is added, llama-3 should have a policy
// Add second worker for llama-3 with different policy hint (should be ignored)
let mut labels2 = HashMap::new();
labels2.insert("policy".to_string(), "random".to_string());
let _worker2_config = WorkerConfigRequest {
url: "http://worker2:8000".to_string(),
model_id: Some("llama-3".to_string()),
api_key: Some("test_api_key".to_string()),
worker_type: None,
priority: None,
cost: None,
labels: labels2,
bootstrap_port: None,
tokenizer_path: None,
reasoning_parser: None,
tool_parser: None,
chat_template: None,
runtime: None,
health_check_timeout_secs: 30,
health_check_interval_secs: 60,
health_success_threshold: 2,
health_failure_threshold: 3,
disable_health_check: false,
max_connection_attempts: 20,
dp_aware: false,
};
// The second worker should use the same policy as the first (cache_aware)
// Add worker for different model (gpt-4) with random policy
let mut labels3 = HashMap::new();
labels3.insert("policy".to_string(), "random".to_string());
let _worker3_config = WorkerConfigRequest {
url: "http://worker3:8000".to_string(),
model_id: Some("gpt-4".to_string()),
api_key: Some("test_api_key".to_string()),
worker_type: None,
priority: None,
cost: None,
labels: labels3,
bootstrap_port: None,
tokenizer_path: None,
reasoning_parser: None,
tool_parser: None,
runtime: None,
chat_template: None,
health_check_timeout_secs: 30,
health_check_interval_secs: 60,
health_success_threshold: 2,
health_failure_threshold: 3,
disable_health_check: false,
max_connection_attempts: 20,
dp_aware: false,
};
let _gpt_policy = policy_registry.get_policy("gpt-4");
// When we remove both llama-3 workers, the policy should be cleaned up
println!("PolicyRegistry integration test structure created");
println!("Note: This test requires mocking or test servers to fully execute");
}
#[test]
fn test_policy_registry_cleanup() {
use smg::{config::PolicyConfig, policies::PolicyRegistry};
let registry = PolicyRegistry::new(PolicyConfig::RoundRobin);
// Add workers for a model
let policy1 = registry.on_worker_added("model-1", Some("cache_aware"));
assert_eq!(policy1.name(), "cache_aware");
// Second worker uses existing policy
let policy2 = registry.on_worker_added("model-1", Some("random"));
assert_eq!(policy2.name(), "cache_aware"); // Should still be cache_aware
assert!(registry.get_policy("model-1").is_some());
// Remove first worker - policy should remain
registry.on_worker_removed("model-1");
assert!(registry.get_policy("model-1").is_some());
// Remove second worker - policy should be cleaned up
registry.on_worker_removed("model-1");
assert!(registry.get_policy("model-1").is_none());
println!("✓ PolicyRegistry cleanup test passed");
}
#[test]
fn test_policy_registry_multiple_models() {
use smg::{config::PolicyConfig, policies::PolicyRegistry};
let registry = PolicyRegistry::new(PolicyConfig::RoundRobin);
// Add workers for different models with different policies
let llama_policy = registry.on_worker_added("llama-3", Some("cache_aware"));
let gpt_policy = registry.on_worker_added("gpt-4", Some("random"));
let mistral_policy = registry.on_worker_added("mistral", None); // Uses default
assert_eq!(llama_policy.name(), "cache_aware");
assert_eq!(gpt_policy.name(), "random");
assert_eq!(mistral_policy.name(), "round_robin"); // Default
assert!(registry.get_policy("llama-3").is_some());
assert!(registry.get_policy("gpt-4").is_some());
assert!(registry.get_policy("mistral").is_some());
// Get all mappings
let mappings = registry.get_all_mappings();
assert_eq!(mappings.len(), 3);
assert_eq!(mappings.get("llama-3").unwrap(), "cache_aware");
assert_eq!(mappings.get("gpt-4").unwrap(), "random");
assert_eq!(mappings.get("mistral").unwrap(), "round_robin");
println!("✓ PolicyRegistry multiple models test passed");
}

View File

@@ -0,0 +1,193 @@
//! Power of Two load balancing integration tests
//!
//! Tests for the Power of Two Choices algorithm that selects the less loaded worker.
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use smg::config::RouterConfig;
use tower::ServiceExt;
use crate::common::{AppTestContext, TestRouterConfig, TestWorkerConfig};
#[cfg(test)]
mod power_of_two_tests {
use super::*;
/// Test that power of two distributes requests across workers
#[tokio::test]
async fn test_power_of_two_distribution() {
let config = TestRouterConfig::power_of_two(3600);
let ctx =
AppTestContext::new_with_config(config, TestWorkerConfig::healthy_workers(19600, 2))
.await;
let app = ctx.create_app().await;
let num_requests = 20;
let mut success_count = 0;
for i in 0..num_requests {
let payload = json!({
"text": format!("Power of two request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
// All requests should succeed
assert_eq!(
success_count, num_requests,
"All requests should succeed with power of two policy"
);
ctx.shutdown().await;
}
/// Test that power of two prefers less loaded workers
#[tokio::test]
async fn test_power_of_two_prefers_less_loaded() {
let config = TestRouterConfig::power_of_two(3601);
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::slow(19602, 200), // Slow worker
TestWorkerConfig::healthy(19603), // Fast worker
],
)
.await;
let app = ctx.create_app().await;
// Send concurrent requests to create load imbalance
let mut handles = Vec::new();
let success_count = Arc::new(AtomicUsize::new(0));
for i in 0..30 {
let app_clone = app.clone();
let success_clone = Arc::clone(&success_count);
let handle = tokio::spawn(async move {
let payload = json!({
"text": format!("Concurrent request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app_clone.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_clone.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
for handle in handles {
handle.await.unwrap();
}
// All requests should succeed
assert!(
success_count.load(Ordering::SeqCst) >= 25,
"Most requests should succeed with power of two"
);
ctx.shutdown().await;
}
/// Test power of two with failing worker uses retry/CB to route to healthy worker
#[tokio::test]
async fn test_power_of_two_with_failing_worker() {
use smg::config::{CircuitBreakerConfig, RetryConfig};
let retry_config = RetryConfig {
max_retries: 3,
initial_backoff_ms: 10,
max_backoff_ms: 50,
..Default::default()
};
let circuit_breaker = CircuitBreakerConfig {
failure_threshold: 2,
success_threshold: 1,
timeout_duration_secs: 2,
window_duration_secs: 10,
};
let config = RouterConfig::builder()
.regular_mode(vec![])
.power_of_two_policy(1)
.host("127.0.0.1")
.port(3602)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.retry_config(retry_config)
.circuit_breaker_config(circuit_breaker)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::flaky(19604, 1.0), // Always fails
TestWorkerConfig::healthy(19605), // Always succeeds
],
)
.await;
let app = ctx.create_app().await;
// Requests should succeed via retry to healthy worker
for i in 0..10 {
let payload = json!({
"text": format!("Request with failing worker {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Request should succeed via retry on healthy worker"
);
}
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,260 @@
//! Service discovery integration tests
//!
//! Tests for service discovery shim functionality for dynamic worker registration.
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use smg::config::RouterConfig;
use tower::ServiceExt;
use crate::common::{
mock_worker::{HealthStatus, MockWorkerConfig, WorkerType},
AppTestContext,
};
#[cfg(test)]
mod service_discovery_tests {
use super::*;
/// Test service discovery endpoint responds correctly
#[tokio::test]
async fn test_service_discovery_endpoint() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4000)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20000,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Check if service discovery endpoint exists
let req = Request::builder()
.method("GET")
.uri("/v1/workers")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
// Endpoint might return OK with worker list or 404 if not implemented
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
"Workers endpoint should respond, got {}",
resp.status()
);
ctx.shutdown().await;
}
/// Test worker registration via discovery shim
#[tokio::test]
async fn test_worker_registration() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4001)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20001,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Register a new worker via discovery endpoint
let register_payload = json!({
"url": "http://127.0.0.1:20002",
"weight": 1.0
});
let req = Request::builder()
.method("POST")
.uri("/register_worker")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&register_payload).unwrap(),
))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
// Registration might succeed or endpoint might not exist
assert!(
resp.status() == StatusCode::OK
|| resp.status() == StatusCode::ACCEPTED
|| resp.status() == StatusCode::NOT_FOUND,
"Registration should respond appropriately, got {}",
resp.status()
);
ctx.shutdown().await;
}
/// Test worker deregistration via discovery shim
#[tokio::test]
async fn test_worker_deregistration() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4002)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![
MockWorkerConfig {
port: 20003,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
},
MockWorkerConfig {
port: 20004,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
},
],
)
.await;
let app = ctx.create_app().await;
// Deregister a worker via discovery endpoint
let deregister_payload = json!({
"url": "http://127.0.0.1:20003"
});
let req = Request::builder()
.method("POST")
.uri("/deregister_worker")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&deregister_payload).unwrap(),
))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert!(
resp.status() == StatusCode::OK
|| resp.status() == StatusCode::ACCEPTED
|| resp.status() == StatusCode::NOT_FOUND,
"Deregistration should respond appropriately, got {}",
resp.status()
);
// Requests should still work with remaining worker
let payload = json!({
"text": "Test after deregistration",
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Request should succeed with remaining worker"
);
ctx.shutdown().await;
}
/// Test health status reporting for discovery
#[tokio::test]
async fn test_health_status_endpoint() {
let config = RouterConfig::builder()
.regular_mode(vec![])
.round_robin_policy()
.host("127.0.0.1")
.port(4003)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![MockWorkerConfig {
port: 20005,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}],
)
.await;
let app = ctx.create_app().await;
// Check health endpoint
let req = Request::builder()
.method("GET")
.uri("/health")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"Health endpoint should return OK when workers are healthy"
);
ctx.shutdown().await;
}
}

View File

@@ -0,0 +1,906 @@
//! Comprehensive integration tests for OpenAI backend functionality
use std::{
collections::HashMap,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
use axum::{
body::Body,
extract::Request,
http::{Method, StatusCode},
response::Response,
routing::post,
Json, Router,
};
use data_connector::{ResponseId, StoredResponse};
use serde_json::json;
use smg::{
config::{ConfigError, HistoryBackend, OracleConfig, RouterConfig, RoutingMode},
protocols::{
chat::{ChatCompletionRequest, ChatMessage, MessageContent},
common::StringOrArray,
completion::CompletionRequest,
generate::GenerateRequest,
responses::{ResponseInput, ResponsesGetParams, ResponsesRequest},
},
routers::{openai::OpenAIRouter, RouterTrait},
};
use tokio::{
net::TcpListener,
time::{sleep, Duration},
};
use tower::ServiceExt;
use crate::common::mock_openai_server::MockOpenAIServer;
/// Helper function to create a minimal chat completion request for testing
fn create_minimal_chat_request() -> ChatCompletionRequest {
let val = json!({
"model": "gpt-3.5-turbo",
"messages": [
{"role": "user", "content": "Hello"}
],
"max_tokens": 100
});
serde_json::from_value(val).unwrap()
}
/// Helper function to create a minimal completion request for testing
fn create_minimal_completion_request() -> CompletionRequest {
CompletionRequest {
model: "gpt-3.5-turbo".to_string(),
prompt: StringOrArray::String("Hello".to_string()),
suffix: None,
max_tokens: Some(100),
temperature: None,
top_p: None,
n: None,
stream: false,
stream_options: None,
logprobs: None,
echo: false,
stop: None,
presence_penalty: None,
frequency_penalty: None,
best_of: None,
logit_bias: None,
user: None,
seed: None,
top_k: None,
min_p: None,
min_tokens: None,
repetition_penalty: None,
regex: None,
ebnf: None,
json_schema: None,
stop_token_ids: None,
no_stop_trim: false,
ignore_eos: false,
skip_special_tokens: true,
lora_path: None,
session_params: None,
return_hidden_states: false,
sampling_seed: None,
other: serde_json::Map::new(),
}
}
/// Test basic OpenAI router creation and configuration
#[tokio::test]
async fn test_openai_router_creation() {
let ctx = crate::common::test_app::create_test_app_context().await;
// Register an external worker before creating the router
crate::common::test_app::register_external_worker(&ctx, "https://api.openai.com", None);
let router = OpenAIRouter::new(&ctx).await;
assert!(router.is_ok(), "Router creation should succeed");
let router = router.unwrap();
assert_eq!(router.router_type(), "openai");
assert!(!router.is_pd_mode());
}
/// Test server info endpoint
#[tokio::test]
async fn test_openai_router_server_info() {
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(&ctx, "https://api.openai.com", None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
let req = Request::builder()
.method(Method::GET)
.uri("/info")
.body(Body::empty())
.unwrap();
let response = router.get_server_info(req).await;
assert_eq!(response.status(), StatusCode::OK);
let (_, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(body_str.contains("openai"));
}
/// Test models endpoint
#[tokio::test]
async fn test_openai_router_models() {
// Use mock server for deterministic models response
let mock_server = MockOpenAIServer::new().await;
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(&ctx, &mock_server.base_url(), None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
let req = Request::builder()
.method(Method::GET)
.uri("/models")
.body(Body::empty())
.unwrap();
let response = router.get_models(req).await;
assert_eq!(response.status(), StatusCode::OK);
let (_, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
let models: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(models["object"], "list");
assert!(models["data"].is_array());
}
#[tokio::test]
async fn test_openai_router_responses_with_mock() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = counter.clone();
let app = Router::new().route(
"/v1/responses",
post({
move |Json(request): Json<serde_json::Value>| {
let counter = counter_clone.clone();
async move {
let idx = counter.fetch_add(1, Ordering::SeqCst) + 1;
let model = request
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("gpt-4o-mini")
.to_string();
let id = format!("resp_mock_{idx}");
let response = json!({
"id": id,
"object": "response",
"created_at": 1_700_000_000 + idx as i64,
"status": "completed",
"model": model,
"output": [{
"type": "message",
"id": format!("msg_{idx}"),
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": format!("mock_output_{idx}"),
"annotations": []
}]
}],
"metadata": {}
});
Json(response)
}
}
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base_url = format!("http://{}", addr);
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(&ctx, &base_url, Some(vec!["gpt-4o-mini"]));
let router = OpenAIRouter::new(&ctx).await.unwrap();
// Get storage from context (router uses this, not a separate storage)
let storage = ctx.response_storage.clone();
let request1 = ResponsesRequest {
model: "gpt-4o-mini".to_string(),
input: ResponseInput::Text("Say hi".to_string()),
store: Some(true),
..Default::default()
};
let response1 = router.route_responses(None, &request1, None).await;
assert_eq!(response1.status(), StatusCode::OK);
let body1_bytes = axum::body::to_bytes(response1.into_body(), usize::MAX)
.await
.unwrap();
let body1: serde_json::Value = serde_json::from_slice(&body1_bytes).unwrap();
let resp1_id = body1["id"].as_str().expect("id missing").to_string();
assert_eq!(body1["previous_response_id"], serde_json::Value::Null);
let request2 = ResponsesRequest {
model: "gpt-4o-mini".to_string(),
input: ResponseInput::Text("Thanks".to_string()),
store: Some(true),
previous_response_id: Some(resp1_id.clone()),
..Default::default()
};
let response2 = router.route_responses(None, &request2, None).await;
assert_eq!(response2.status(), StatusCode::OK);
let body2_bytes = axum::body::to_bytes(response2.into_body(), usize::MAX)
.await
.unwrap();
let body2: serde_json::Value = serde_json::from_slice(&body2_bytes).unwrap();
let resp2_id = body2["id"].as_str().expect("second id missing");
assert_eq!(
body2["previous_response_id"].as_str(),
Some(resp1_id.as_str())
);
let stored1 = storage
.get_response(&ResponseId::from(resp1_id.clone()))
.await
.unwrap()
.expect("first response missing");
// Input is now stored as a JSON array of items
assert!(stored1.input.is_array());
let input_items = stored1.input.as_array().unwrap();
assert_eq!(input_items.len(), 1);
assert_eq!(input_items[0]["type"], "message");
assert_eq!(input_items[0]["role"], "user");
assert_eq!(input_items[0]["content"][0]["text"], "Say hi");
// Output is now stored as a JSON array of items
assert!(stored1.output.is_array());
let output_items = stored1.output.as_array().unwrap();
assert_eq!(output_items.len(), 1);
assert_eq!(output_items[0]["content"][0]["text"], "mock_output_1");
assert!(stored1.previous_response_id.is_none());
let stored2 = storage
.get_response(&ResponseId::from(resp2_id))
.await
.unwrap()
.expect("second response missing");
assert_eq!(stored2.previous_response_id.unwrap().0, resp1_id);
// Output is now stored as a JSON array
assert!(stored2.output.is_array());
let output_items2 = stored2.output.as_array().unwrap();
assert_eq!(output_items2.len(), 1);
assert_eq!(output_items2[0]["content"][0]["text"], "mock_output_2");
let get1 = router
.get_response(None, &stored1.id.0, &ResponsesGetParams::default())
.await;
assert_eq!(get1.status(), StatusCode::OK);
let get1_body_bytes = axum::body::to_bytes(get1.into_body(), usize::MAX)
.await
.unwrap();
let get1_json: serde_json::Value = serde_json::from_slice(&get1_body_bytes).unwrap();
assert_eq!(get1_json, body1);
let get2 = router
.get_response(None, &stored2.id.0, &ResponsesGetParams::default())
.await;
assert_eq!(get2.status(), StatusCode::OK);
let get2_body_bytes = axum::body::to_bytes(get2.into_body(), usize::MAX)
.await
.unwrap();
let get2_json: serde_json::Value = serde_json::from_slice(&get2_body_bytes).unwrap();
assert_eq!(get2_json, body2);
server.abort();
}
#[tokio::test]
async fn test_openai_router_responses_streaming_with_mock() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let sse_handler = post(|Json(_request): Json<serde_json::Value>| async move {
let response_id = "resp_stream_123";
let message_id = "msg_stream_123";
let final_text = "Once upon a streamed unicorn adventure.";
let events = vec![
(
"response.created",
json!({
"type": "response.created",
"sequence_number": 0,
"response": {
"id": response_id,
"object": "response",
"created_at": 1_700_000_500,
"status": "in_progress",
"model": "",
"output": [],
"parallel_tool_calls": true,
"previous_response_id": null,
"reasoning": null,
"store": false,
"temperature": 1.0,
"text": {"format": {"type": "text"}},
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"truncation": "disabled",
"usage": null,
"metadata": null
}
}),
),
(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"sequence_number": 1,
"output_index": 0,
"item": {
"id": message_id,
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": []
}
}),
),
(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"sequence_number": 2,
"item_id": message_id,
"output_index": 0,
"content_index": 0,
"delta": "Once upon a streamed unicorn adventure.",
"logprobs": []
}),
),
(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"sequence_number": 3,
"item_id": message_id,
"output_index": 0,
"content_index": 0,
"text": final_text,
"logprobs": []
}),
),
(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"sequence_number": 4,
"output_index": 0,
"item": {
"id": message_id,
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": final_text,
"annotations": [],
"logprobs": []
}]
}
}),
),
(
"response.completed",
json!({
"type": "response.completed",
"sequence_number": 5,
"response": {
"id": response_id,
"object": "response",
"created_at": 1_700_000_500,
"status": "completed",
"model": "",
"output": [{
"id": message_id,
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": final_text,
"annotations": [],
"logprobs": []
}]
}],
"parallel_tool_calls": true,
"previous_response_id": null,
"reasoning": null,
"store": false,
"temperature": 1.0,
"text": {"format": {"type": "text"}},
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"truncation": "disabled",
"usage": {
"input_tokens": 10,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens": 20,
"output_tokens_details": {"reasoning_tokens": 5},
"total_tokens": 30
},
"metadata": null,
"instructions": null,
"user": null
}
}),
),
];
let sse_payload = events
.into_iter()
.map(|(event, data)| format!("event: {}\ndata: {}\n\n", event, data))
.collect::<String>();
Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/event-stream")
.body(Body::from(sse_payload))
.unwrap()
});
let app = Router::new().route("/v1/responses", sse_handler);
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base_url = format!("http://{}", addr);
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(&ctx, &base_url, Some(vec!["gpt-5-nano"]));
let router = OpenAIRouter::new(&ctx).await.unwrap();
// Get storage from context and seed a previous response
let storage = ctx.response_storage.clone();
let mut previous = StoredResponse::new(None);
previous.id = ResponseId::from("resp_prev_chain");
previous.input = serde_json::json!("Earlier bedtime question");
previous.output = serde_json::json!("Earlier answer");
storage.store_response(previous).await.unwrap();
let mut metadata = HashMap::new();
metadata.insert("topic".to_string(), json!("unicorns"));
let request = ResponsesRequest {
model: "gpt-5-nano".to_string(),
input: ResponseInput::Text("Tell me a bedtime story.".to_string()),
instructions: Some("Be kind".to_string()),
metadata: Some(metadata),
previous_response_id: Some("resp_prev_chain".to_string()),
store: Some(true),
stream: Some(true),
..Default::default()
};
let response = router.route_responses(None, &request, None).await;
assert_eq!(response.status(), StatusCode::OK);
let headers = response.headers();
let ct = headers
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.to_ascii_lowercase();
assert!(ct.contains("text/event-stream"));
let response_body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_text = String::from_utf8(response_body.to_vec()).unwrap();
assert!(body_text.contains("response.completed"));
assert!(body_text.contains("Once upon a streamed unicorn adventure."));
// Wait for the storage task to persist the streaming response.
let target_id = ResponseId::from("resp_stream_123");
let stored = loop {
if let Some(resp) = storage.get_response(&target_id).await.unwrap() {
break resp;
}
sleep(Duration::from_millis(10)).await;
};
// Input is now stored as a JSON array of items
assert!(stored.input.is_array());
let input_items = stored.input.as_array().unwrap();
assert_eq!(input_items.len(), 1);
assert_eq!(input_items[0]["type"], "message");
assert_eq!(input_items[0]["role"], "user");
assert_eq!(
input_items[0]["content"][0]["text"],
"Tell me a bedtime story."
);
// Output is now stored as a JSON array of items
assert!(stored.output.is_array());
let output_items = stored.output.as_array().unwrap();
assert_eq!(output_items.len(), 1);
assert_eq!(
output_items[0]["content"][0]["text"],
"Once upon a streamed unicorn adventure."
);
assert_eq!(
stored
.previous_response_id
.as_ref()
.expect("previous_response_id missing")
.0,
"resp_prev_chain"
);
assert_eq!(stored.metadata.get("topic"), Some(&json!("unicorns")));
assert_eq!(stored.instructions.as_deref(), Some("Be kind"));
assert_eq!(stored.model.as_deref(), Some("gpt-5-nano"));
assert_eq!(stored.safety_identifier, None);
assert_eq!(stored.raw_response["store"], json!(true));
assert_eq!(
stored.raw_response["previous_response_id"].as_str(),
Some("resp_prev_chain")
);
assert_eq!(stored.raw_response["metadata"]["topic"], json!("unicorns"));
assert_eq!(
stored.raw_response["instructions"].as_str(),
Some("Be kind")
);
server.abort();
}
/// Test router factory with OpenAI routing mode
#[tokio::test]
async fn test_router_factory_openai_mode() {
let routing_mode = RoutingMode::OpenAI {
worker_urls: vec!["https://api.openai.com".to_string()],
};
let router_config = RouterConfig::new(routing_mode, smg::config::PolicyConfig::Random);
let app_context = crate::common::create_test_context(router_config).await;
let router = smg::routers::RouterFactory::create_router(&app_context).await;
assert!(
router.is_ok(),
"Router factory should create OpenAI router successfully"
);
let router = router.unwrap();
assert_eq!(router.router_type(), "openai");
}
/// Test that unsupported endpoints return proper error codes
#[tokio::test]
async fn test_unsupported_endpoints() {
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(&ctx, "https://api.openai.com", None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
let generate_request = GenerateRequest {
text: Some("Hello world".to_string()),
model: None,
input_ids: None,
input_embeds: None,
image_data: None,
video_data: None,
audio_data: None,
sampling_params: None,
return_logprob: Some(false),
logprob_start_len: None,
top_logprobs_num: None,
token_ids_logprob: None,
return_text_in_logprobs: false,
stream: false,
log_metrics: true,
return_hidden_states: false,
modalities: None,
session_params: None,
lora_path: None,
lora_id: None,
custom_logit_processor: None,
bootstrap_host: None,
bootstrap_port: None,
bootstrap_room: None,
bootstrap_pair_key: None,
data_parallel_rank: None,
background: false,
conversation_id: None,
priority: None,
extra_key: None,
no_logs: false,
custom_labels: None,
return_bytes: false,
return_entropy: false,
rid: None,
};
let response = router.route_generate(None, &generate_request, None).await;
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let completion_request = create_minimal_completion_request();
let response = router
.route_completion(None, &completion_request, None)
.await;
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
/// Test chat completion with mock OpenAI server
#[tokio::test]
async fn test_openai_router_chat_completion_with_mock() {
// Start a mock OpenAI server
let mock_server = MockOpenAIServer::new().await;
let base_url = mock_server.base_url();
let ctx = crate::common::test_app::create_test_app_context().await;
// Register the mock server worker and create router
crate::common::test_app::register_external_worker(&ctx, &base_url, None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
// Create a minimal chat completion request
let mut chat_request = create_minimal_chat_request();
chat_request.messages = vec![ChatMessage::User {
content: MessageContent::Text("Hello, how are you?".to_string()),
name: None,
}];
chat_request.temperature = Some(0.7);
// Route the request
let response = router.route_chat(None, &chat_request, None).await;
// Should get a successful response from mock server
assert_eq!(response.status(), StatusCode::OK);
let (_, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
let chat_response: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(chat_response["object"], "chat.completion");
assert_eq!(chat_response["model"], "gpt-3.5-turbo");
assert!(!chat_response["choices"].as_array().unwrap().is_empty());
}
/// Test full E2E flow with Axum server
#[tokio::test]
async fn test_openai_e2e_with_server() {
// Start mock OpenAI server
let mock_server = MockOpenAIServer::new().await;
let base_url = mock_server.base_url();
let ctx = crate::common::test_app::create_test_app_context().await;
// Register the mock server worker and create router
crate::common::test_app::register_external_worker(&ctx, &base_url, None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
// Create Axum app with chat completions endpoint
let app = Router::new().route(
"/v1/chat/completions",
post({
let router = Arc::new(router);
move |req: Request<Body>| {
let router = router.clone();
async move {
let (parts, body) = req.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
let chat_request: ChatCompletionRequest =
serde_json::from_str(&body_str).unwrap();
router
.route_chat(Some(&parts.headers), &chat_request, None)
.await
}
}
}),
);
// Make a request to the server
let request = Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": "Hello, world!"
}
],
"max_tokens": 100
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let response_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(response_json["object"], "chat.completion");
assert_eq!(response_json["model"], "gpt-3.5-turbo");
assert!(!response_json["choices"].as_array().unwrap().is_empty());
}
/// Test streaming chat completions pass-through with mock server
#[tokio::test]
async fn test_openai_router_chat_streaming_with_mock() {
let mock_server = MockOpenAIServer::new().await;
let base_url = mock_server.base_url();
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(&ctx, &base_url, None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
// Build a streaming chat request
let val = json!({
"model": "gpt-3.5-turbo",
"messages": [
{"role": "user", "content": "Hello"}
],
"max_tokens": 10,
"stream": true
});
let chat_request: ChatCompletionRequest = serde_json::from_value(val).unwrap();
let response = router.route_chat(None, &chat_request, None).await;
assert_eq!(response.status(), StatusCode::OK);
// Should be SSE
let headers = response.headers();
let ct = headers
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.to_ascii_lowercase();
assert!(ct.contains("text/event-stream"));
// Read entire stream body and assert chunks + DONE
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("chat.completion.chunk"));
assert!(text.contains("[DONE]"));
}
/// Test circuit breaker functionality
#[tokio::test]
async fn test_openai_router_circuit_breaker() {
let ctx = crate::common::test_app::create_test_app_context().await;
crate::common::test_app::register_external_worker(
&ctx,
"http://invalid-url-that-will-fail",
None,
);
let router = OpenAIRouter::new(&ctx).await.unwrap();
let chat_request = create_minimal_chat_request();
// First few requests should fail and record failures
for _ in 0..3 {
let response = router.route_chat(None, &chat_request, None).await;
// Should get either an error or circuit breaker response
assert!(
response.status() == StatusCode::INTERNAL_SERVER_ERROR
|| response.status() == StatusCode::SERVICE_UNAVAILABLE
);
}
}
/// Test that /v1/models returns models from registered workers' ModelCards
///
/// With the new worker-based design, models are returned from the WorkerRegistry
/// and don't require calling external APIs. Auth headers are used for routing
/// requests to workers, not for the models endpoint.
#[tokio::test]
async fn test_openai_router_models_from_registry() {
let ctx = crate::common::test_app::create_test_app_context().await;
// Register a worker with the default model
crate::common::test_app::register_external_worker(&ctx, "https://api.example.com", None);
let router = OpenAIRouter::new(&ctx).await.unwrap();
// Get models - should return the registered model
let req = Request::builder()
.method(Method::GET)
.uri("/models")
.body(Body::empty())
.unwrap();
let response = router.get_models(req).await;
assert_eq!(response.status(), StatusCode::OK);
let (_, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
let models: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(models["object"], "list");
// Should have the default model (gpt-3.5-turbo)
let data = models["data"].as_array().unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0]["id"], "gpt-3.5-turbo");
}
#[test]
fn oracle_config_validation_requires_config_when_enabled() {
let config = RouterConfig::builder()
.openai_mode(vec!["https://api.openai.com".to_string()])
.history_backend(HistoryBackend::Oracle)
.build_unchecked();
let err = config
.validate()
.expect_err("config should fail without oracle details");
match err {
ConfigError::MissingRequired { field } => {
assert_eq!(field, "oracle");
}
other => panic!("unexpected error: {:?}", other),
}
}
#[test]
fn oracle_config_validation_accepts_dsn_only() {
let config = RouterConfig::builder()
.openai_mode(vec!["https://api.openai.com".to_string()])
.oracle_history(OracleConfig {
wallet_path: None,
connect_descriptor: "tcps://db.example.com:1522/service".to_string(),
username: "scott".to_string(),
password: "tiger".to_string(),
pool_min: 1,
pool_max: 4,
pool_timeout_secs: 30,
})
.build_unchecked();
config.validate().expect("dsn-based config should validate");
}
#[test]
fn oracle_config_validation_accepts_wallet_alias() {
let config = RouterConfig::builder()
.openai_mode(vec!["https://api.openai.com".to_string()])
.oracle_history(OracleConfig {
wallet_path: Some("/etc/sglang/oracle-wallet".to_string()),
connect_descriptor: "db_low".to_string(),
username: "app_user".to_string(),
password: "secret".to_string(),
pool_min: 1,
pool_max: 8,
pool_timeout_secs: 45,
})
.build_unchecked();
config
.validate()
.expect("wallet-based config should validate");
}

View File

@@ -0,0 +1,926 @@
#[cfg(test)]
mod pd_routing_unit_tests {
use serde_json::json;
use smg::{
app_context::AppContext,
config::{PolicyConfig, RouterConfig, RoutingMode},
core::{BasicWorkerBuilder, Worker, WorkerType},
routers::{http::pd_types::PDSelectionPolicy, RouterFactory},
tokenizer::registry::TokenizerRegistry,
};
#[derive(Debug)]
struct PDRequest {
pub is_stream: bool,
pub batch_size: Option<usize>,
}
impl PDRequest {
pub fn from_json(json: &serde_json::Value) -> Self {
let is_stream = json
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let batch_size = if let Some(text) = json.get("text") {
text.as_array().map(|arr| arr.len())
} else if let Some(input_ids) = json.get("input_ids") {
input_ids.as_array().map(|arr| arr.len())
} else {
None
};
PDRequest {
is_stream,
batch_size,
}
}
}
#[test]
fn test_worker_types() {
use smg::core::{BasicWorkerBuilder, Worker, WorkerType};
let prefill_worker: Box<dyn Worker> = Box::new(
BasicWorkerBuilder::new("http://prefill:8080")
.worker_type(WorkerType::Prefill {
bootstrap_port: Some(9000),
})
.api_key("test_api_key")
.build(),
);
assert_eq!(prefill_worker.url(), "http://prefill:8080");
match prefill_worker.worker_type() {
WorkerType::Prefill { bootstrap_port } => {
assert_eq!(*bootstrap_port, Some(9000));
}
_ => panic!("Expected Prefill worker type"),
}
let decode_worker: Box<dyn Worker> = Box::new(
BasicWorkerBuilder::new("http://decode:8080")
.worker_type(WorkerType::Decode)
.api_key("test_api_key")
.build(),
);
assert_eq!(decode_worker.url(), "http://decode:8080");
match decode_worker.worker_type() {
WorkerType::Decode => (),
_ => panic!("Expected Decode worker type"),
}
let regular_worker: Box<dyn Worker> = Box::new(
BasicWorkerBuilder::new("http://regular:8080")
.worker_type(WorkerType::Regular)
.api_key("test_api_key")
.build(),
);
assert_eq!(regular_worker.url(), "http://regular:8080");
match regular_worker.worker_type() {
WorkerType::Regular => (),
_ => panic!("Expected Regular worker type"),
}
}
#[test]
fn test_pd_selection_policies() {
// Note: These policies are only used when pd_disaggregation=true
let policies = vec![
PDSelectionPolicy::Random,
PDSelectionPolicy::PowerOfTwo,
PDSelectionPolicy::CacheAware {
cache_threshold: 0.5,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
PDSelectionPolicy::Bucket {
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
bucket_adjust_interval_secs: 5,
},
];
for policy in policies {
match &policy {
PDSelectionPolicy::Random => {
assert!(matches!(policy, PDSelectionPolicy::Random));
}
PDSelectionPolicy::PowerOfTwo => {
assert!(matches!(policy, PDSelectionPolicy::PowerOfTwo));
}
PDSelectionPolicy::CacheAware {
cache_threshold, ..
} => {
assert!(*cache_threshold >= 0.0 && *cache_threshold <= 1.0);
}
PDSelectionPolicy::Bucket {
balance_rel_threshold,
..
} => {
assert!(*balance_rel_threshold >= 1.0);
}
}
}
}
#[tokio::test]
async fn test_pd_router_configuration() {
// In the new structure, RoutingMode and PolicyConfig are separate
let test_cases = vec![
(
RoutingMode::PrefillDecode {
prefill_urls: vec![
("http://prefill1:8080".to_string(), Some(9000)),
("http://prefill2:8080".to_string(), None),
],
decode_urls: vec![
"http://decode1:8080".to_string(),
"http://decode2:8080".to_string(),
],
prefill_policy: None,
decode_policy: None,
},
PolicyConfig::Random,
),
(
RoutingMode::PrefillDecode {
prefill_urls: vec![("http://prefill:8080".to_string(), Some(9000))],
decode_urls: vec!["http://decode:8080".to_string()],
prefill_policy: None,
decode_policy: None,
},
PolicyConfig::PowerOfTwo {
load_check_interval_secs: 5,
},
),
(
RoutingMode::PrefillDecode {
prefill_urls: vec![
("http://p1:8080".to_string(), Some(9000)),
("http://p2:8080".to_string(), Some(9001)),
("http://p3:8080".to_string(), Some(9002)),
],
decode_urls: vec!["http://d1:8080".to_string(), "http://d2:8080".to_string()],
prefill_policy: None,
decode_policy: None,
},
PolicyConfig::CacheAware {
cache_threshold: 0.7,
balance_abs_threshold: 20,
balance_rel_threshold: 1.2,
eviction_interval_secs: 60,
max_tree_size: 1000000,
},
),
(
RoutingMode::PrefillDecode {
prefill_urls: vec![
("http://p1:8080".to_string(), Some(9000)),
("http://p2:8080".to_string(), Some(9001)),
("http://p3:8080".to_string(), Some(9002)),
],
decode_urls: vec!["http://d1:8080".to_string(), "http://d2:8080".to_string()],
prefill_policy: None,
decode_policy: None,
},
PolicyConfig::Bucket {
balance_abs_threshold: 20,
balance_rel_threshold: 1.2,
bucket_adjust_interval_secs: 5,
},
),
];
for (mode, policy) in test_cases {
let config = match mode {
RoutingMode::PrefillDecode {
prefill_urls,
decode_urls,
..
} => RouterConfig::builder()
.prefill_decode_mode(prefill_urls, decode_urls)
.policy(policy)
.host("127.0.0.1")
.port(3001)
.max_payload_size(1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(10)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked(),
_ => panic!("Expected PrefillDecode mode"),
};
let app_context = {
use std::sync::{Arc, OnceLock};
use data_connector::{
MemoryConversationItemStorage, MemoryConversationStorage, MemoryResponseStorage,
};
use smg::{
core::{LoadMonitor, WorkerRegistry},
middleware::TokenBucket,
policies::PolicyRegistry,
};
let client = reqwest::Client::new();
// Initialize rate limiter
let rate_limiter = Some(Arc::new(TokenBucket::new(64, 64)));
// Initialize registries
let worker_registry = Arc::new(WorkerRegistry::new());
let policy_registry = Arc::new(PolicyRegistry::new(config.policy.clone()));
// Initialize storage backends
let response_storage = Arc::new(MemoryResponseStorage::new());
let conversation_storage = Arc::new(MemoryConversationStorage::new());
let conversation_item_storage = Arc::new(MemoryConversationItemStorage::new());
// Initialize load monitor
let load_monitor = Some(Arc::new(LoadMonitor::new(
worker_registry.clone(),
policy_registry.clone(),
client.clone(),
config.worker_startup_check_interval_secs,
)));
// Create empty OnceLock for worker job queue, workflow engines, and mcp manager
let worker_job_queue = Arc::new(OnceLock::new());
let workflow_engines = Arc::new(OnceLock::new());
let mcp_manager = Arc::new(OnceLock::new());
Arc::new(
AppContext::builder()
.router_config(config)
.client(client)
.rate_limiter(rate_limiter)
.tokenizer_registry(Arc::new(TokenizerRegistry::new())) // tokenizer
.reasoning_parser_factory(None) // reasoning_parser_factory
.tool_parser_factory(None) // tool_parser_factory
.worker_registry(worker_registry)
.policy_registry(policy_registry)
.response_storage(response_storage)
.conversation_storage(conversation_storage)
.conversation_item_storage(conversation_item_storage)
.load_monitor(load_monitor)
.worker_job_queue(worker_job_queue)
.workflow_engines(workflow_engines)
.mcp_manager(mcp_manager)
.build()
.unwrap(),
)
};
let result = RouterFactory::create_router(&app_context).await;
assert!(
result.is_ok(),
"Router creation should succeed with empty worker"
);
let stats = app_context.worker_registry.stats();
assert_eq!(
stats.total_workers, 0,
"No workers should be registered without initialization"
);
}
}
#[test]
fn test_pd_request_from_json() {
let single_json = json!({
"text": "Hello world",
"stream": false,
"temperature": 0.7,
"max_tokens": 100
});
let pd_req = PDRequest::from_json(&single_json);
assert!(!pd_req.is_stream);
assert_eq!(pd_req.batch_size, None);
let batch_json = json!({
"text": ["Hello", "World", "Test"],
"stream": true,
"temperature": 0.5
});
let pd_req = PDRequest::from_json(&batch_json);
assert!(pd_req.is_stream);
assert_eq!(pd_req.batch_size, Some(3));
let ids_json = json!({
"input_ids": [[1, 2, 3], [4, 5, 6]],
"stream": false
});
let pd_req = PDRequest::from_json(&ids_json);
assert!(!pd_req.is_stream);
assert_eq!(pd_req.batch_size, Some(2));
let chat_json = json!({
"messages": [
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello"}
],
"stream": true
});
let pd_req = PDRequest::from_json(&chat_json);
assert!(pd_req.is_stream);
assert_eq!(pd_req.batch_size, None);
}
#[test]
fn test_bootstrap_injection_simulation() {
// Since we can't test the actual inject_bootstrap_fields function here
// (it's private in the router module), we'll test the expected behavior
let mut single_json = json!({
"text": "Hello world",
"stream": false,
"temperature": 0.7
});
let prefill_worker: Box<dyn Worker> = Box::new(
BasicWorkerBuilder::new("http://prefill1:8080")
.worker_type(WorkerType::Prefill {
bootstrap_port: Some(9000),
})
.api_key("test_api_key")
.build(),
);
let bootstrap_port = match prefill_worker.worker_type() {
WorkerType::Prefill { bootstrap_port } => bootstrap_port,
_ => &None,
};
single_json["bootstrap_host"] = json!(prefill_worker.bootstrap_host());
single_json["bootstrap_port"] = json!(bootstrap_port);
single_json["bootstrap_room"] = json!(12345u64); // Random room ID
assert_eq!(single_json["bootstrap_host"], "prefill1");
assert_eq!(single_json["bootstrap_port"], json!(Some(9000)));
assert!(single_json["bootstrap_room"].is_u64());
assert_eq!(single_json["temperature"], 0.7); // Original field preserved
let mut batch_json = json!({
"text": ["Hello", "World", "Test"],
"stream": true
});
let batch_size = 3;
let hostname = prefill_worker.bootstrap_host();
batch_json["bootstrap_host"] = json!(vec![hostname; batch_size]);
batch_json["bootstrap_port"] = json!(vec![bootstrap_port; batch_size]);
batch_json["bootstrap_room"] = json!(vec![111u64, 222u64, 333u64]);
assert!(batch_json["bootstrap_host"].is_array());
assert_eq!(
batch_json["bootstrap_host"].as_array().unwrap().len(),
batch_size
);
assert!(batch_json["bootstrap_port"].is_array());
assert!(batch_json["bootstrap_room"].is_array());
assert_eq!(batch_json["stream"], true); // Original field preserved
}
#[test]
fn test_request_serialization() {
let request = json!({
"text": "Test prompt",
"stream": false,
"temperature": 0.7,
"max_tokens": 100,
"top_p": 0.9,
"frequency_penalty": 0.5,
"bootstrap_host": "prefill1",
"bootstrap_port": 9000,
"bootstrap_room": 12345u64
});
let bytes = serde_json::to_vec(&request).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["text"], "Test prompt");
assert_eq!(parsed["stream"], false);
assert_eq!(parsed["temperature"], 0.7);
assert_eq!(parsed["max_tokens"], 100);
assert_eq!(parsed["bootstrap_host"], "prefill1");
assert_eq!(parsed["bootstrap_port"], 9000);
assert_eq!(parsed["bootstrap_room"], 12345);
}
#[test]
fn test_pd_request_edge_cases() {
let empty_json = json!({});
let pd_req = PDRequest::from_json(&empty_json);
assert!(!pd_req.is_stream);
assert_eq!(pd_req.batch_size, None);
let stream_only = json!({
"stream": true
});
let pd_req = PDRequest::from_json(&stream_only);
assert!(pd_req.is_stream);
assert_eq!(pd_req.batch_size, None);
let empty_batch = json!({
"text": []
});
let pd_req = PDRequest::from_json(&empty_batch);
assert_eq!(pd_req.batch_size, Some(0));
let non_array_text = json!({
"text": "single string"
});
let pd_req = PDRequest::from_json(&non_array_text);
assert_eq!(pd_req.batch_size, None);
}
#[tokio::test]
async fn test_background_load_monitoring() {
use std::collections::HashMap;
use tokio::sync::watch;
let (tx, rx) = watch::channel(HashMap::new());
let mut loads = HashMap::new();
loads.insert("http://prefill1:8080".to_string(), 10);
loads.insert("http://prefill2:8080".to_string(), 20);
loads.insert("http://decode1:8080".to_string(), 5);
loads.insert("http://decode2:8080".to_string(), 15);
tx.send(loads.clone()).unwrap();
let received_loads = rx.borrow();
assert_eq!(received_loads.get("http://prefill1:8080"), Some(&10));
assert_eq!(received_loads.get("http://prefill2:8080"), Some(&20));
assert_eq!(received_loads.get("http://decode1:8080"), Some(&5));
assert_eq!(received_loads.get("http://decode2:8080"), Some(&15));
}
#[test]
fn test_load_monitoring_configuration() {
let policies = vec![
(PDSelectionPolicy::Random, false),
(PDSelectionPolicy::PowerOfTwo, true),
(
PDSelectionPolicy::CacheAware {
cache_threshold: 0.5,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
},
false,
),
];
for (policy, should_monitor) in policies {
match policy {
PDSelectionPolicy::PowerOfTwo => assert!(should_monitor),
_ => assert!(!should_monitor),
}
}
}
#[tokio::test]
async fn test_watch_channel_behavior() {
use std::collections::HashMap;
use tokio::sync::watch;
let (tx, rx1) = watch::channel(HashMap::new());
let rx2 = rx1.clone();
assert!(rx1.borrow().is_empty());
assert!(rx2.borrow().is_empty());
let mut loads = HashMap::new();
loads.insert("worker1".to_string(), 10);
tx.send(loads.clone()).unwrap();
assert_eq!(rx1.borrow().get("worker1"), Some(&10));
assert_eq!(rx2.borrow().get("worker1"), Some(&10));
loads.insert("worker1".to_string(), 20);
loads.insert("worker2".to_string(), 30);
tx.send(loads).unwrap();
assert_eq!(rx1.borrow().get("worker1"), Some(&20));
assert_eq!(rx2.borrow().get("worker2"), Some(&30));
}
#[test]
fn test_generate_request_formats() {
// Based on bench_one_batch_server.py request patterns
let batch_request = json!({
"input_ids": [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]],
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 16,
"ignore_eos": true,
},
"return_logprob": false,
"stream": true
});
let pd_req = PDRequest::from_json(&batch_request);
assert!(pd_req.is_stream);
assert_eq!(pd_req.batch_size, Some(3));
let logprob_request = json!({
"input_ids": [[1, 2, 3]],
"sampling_params": {
"temperature": 0.7,
"max_new_tokens": 8,
},
"return_logprob": true,
"stream": false
});
assert_eq!(logprob_request["return_logprob"], true);
assert_eq!(logprob_request["stream"], false);
let batch_sizes = vec![1, 16, 64]; // From bench_one_batch_server.py
for bs in batch_sizes {
let request = json!({
"input_ids": vec![vec![1, 2, 3]; bs],
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 16,
},
"stream": true
});
let pd_req = PDRequest::from_json(&request);
assert_eq!(pd_req.batch_size, Some(bs));
}
}
#[test]
fn test_sampling_params_handling() {
let sampling_params_variations = vec![
json!({
"temperature": 0.0,
"max_new_tokens": 8,
"ignore_eos": true
}),
json!({
"temperature": 0.7,
"max_new_tokens": 16,
"ignore_eos": false,
"top_p": 0.9,
"frequency_penalty": 0.5
}),
json!({
"temperature": 1.0,
"max_new_tokens": 64,
"json_schema": "$$ANY$$" // Structured output
}),
];
for params in sampling_params_variations {
let request = json!({
"input_ids": [[1, 2, 3]],
"sampling_params": params.clone(),
"stream": false
});
assert_eq!(request["sampling_params"], params);
}
}
#[test]
fn test_streaming_response_parsing() {
let sse_chunks = ["data: {\"text\":\"Hello\",\"meta_info\":{\"completion_tokens\":1,\"finish_reason\":null}}",
"data: {\"text\":\" world\",\"meta_info\":{\"completion_tokens\":2,\"finish_reason\":null}}",
"data: {\"text\":\"!\",\"meta_info\":{\"completion_tokens\":3,\"finish_reason\":{\"type\":\"length\"}}}",
"data: [DONE]"];
for chunk in &sse_chunks[..3] {
assert!(chunk.starts_with("data: "));
let json_str = &chunk[6..]; // Skip "data: "
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert!(parsed["meta_info"]["completion_tokens"].is_u64());
}
assert_eq!(sse_chunks[3], "data: [DONE]");
}
#[test]
fn test_ttft_calculation() {
let first_token_response = json!({
"text": "Hello",
"meta_info": {
"completion_tokens": 1,
"finish_reason": null
}
});
// TTFT is calculated when completion_tokens == 1
assert_eq!(first_token_response["meta_info"]["completion_tokens"], 1);
assert!(first_token_response["meta_info"]["finish_reason"].is_null());
}
#[test]
fn test_throughput_metrics() {
let batch_size = 16;
let input_len = 1024;
let output_len = 16;
let ttft = 0.5; // seconds
let total_latency = 2.0; // seconds
// Input throughput = batch_size * input_len / ttft
let input_throughput = (batch_size as f64) * (input_len as f64) / ttft;
assert!((input_throughput - 32768.0).abs() < 0.01);
// Output throughput = batch_size * output_len / (latency - ttft)
let output_throughput = (batch_size as f64) * (output_len as f64) / (total_latency - ttft);
assert!((output_throughput - 170.67).abs() < 0.01);
}
#[test]
fn test_error_response_handling() {
let error_response = json!({
"error": "Request has failed. Invalid input format."
});
assert!(error_response.get("error").is_some());
assert!(error_response["error"].as_str().unwrap().contains("failed"));
}
#[test]
fn test_structured_output_request() {
let structured_request = json!({
"text": "What is the capital of France? Answer in JSON.",
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 64,
"json_schema": "$$ANY$$"
},
"stream": false
});
assert_eq!(
structured_request["sampling_params"]["json_schema"],
"$$ANY$$"
);
}
#[test]
fn test_bootstrap_injection_with_benchmark_requests() {
use smg::core::{BasicWorkerBuilder, Worker, WorkerType};
let mut benchmark_request = json!({
"input_ids": vec![vec![1, 2, 3, 4]; 16], // Batch size 16
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 8,
"ignore_eos": true
},
"return_logprob": true,
"stream": true
});
let prefill_worker: Box<dyn Worker> = Box::new(
BasicWorkerBuilder::new("http://prefill:8080")
.worker_type(WorkerType::Prefill {
bootstrap_port: Some(9000),
})
.api_key("test_api_key")
.build(),
);
let bootstrap_port = match prefill_worker.worker_type() {
WorkerType::Prefill { bootstrap_port } => bootstrap_port,
_ => &None,
};
let batch_size = 16;
let hostname = prefill_worker.bootstrap_host();
benchmark_request["bootstrap_host"] = json!(vec![hostname; batch_size]);
benchmark_request["bootstrap_port"] = json!(vec![bootstrap_port; batch_size]);
benchmark_request["bootstrap_room"] =
json!((0..batch_size).map(|_| 12345u64).collect::<Vec<_>>());
assert_eq!(
benchmark_request["bootstrap_host"]
.as_array()
.unwrap()
.len(),
batch_size
);
assert_eq!(
benchmark_request["bootstrap_port"]
.as_array()
.unwrap()
.len(),
batch_size
);
assert_eq!(
benchmark_request["bootstrap_room"]
.as_array()
.unwrap()
.len(),
batch_size
);
assert_eq!(benchmark_request["return_logprob"], true);
assert_eq!(benchmark_request["stream"], true);
}
#[test]
fn test_server_info_response_format() {
let server_info = json!({
"internal_states": [{
"avg_spec_accept_length": 3.5,
"last_gen_throughput": 2048.5,
"load": 16
}],
"prefill": [
{"url": "http://prefill1:8080", "load": 10},
{"url": "http://prefill2:8080", "load": 20}
],
"decode": [
{"url": "http://decode1:8080", "load": 5},
{"url": "http://decode2:8080", "load": 15}
]
});
assert!(server_info["internal_states"][0]["avg_spec_accept_length"].is_f64());
assert!(server_info["internal_states"][0]["last_gen_throughput"].is_f64());
assert!(server_info["prefill"].is_array());
assert!(server_info["decode"].is_array());
}
// Comprehensive Endpoint Coverage Test
#[test]
fn test_pd_endpoints_coverage() {
// Document all endpoints from Python mini_lb.py and verify implementation status
let implemented_endpoints = vec![
("/health", "GET", true),
("/health_generate", "GET", true), // Note: Python uses POST, we use GET
("/server_info", "GET", true),
("/v1/models", "GET", true),
("/get_model_info", "GET", true),
("/generate", "POST", true),
("/v1/chat/completions", "POST", true),
("/v1/completions", "POST", true),
("/flush_cache", "POST", true),
("/get_loads", "GET", true),
("/register", "POST", false), // NOT IMPLEMENTED - needs dynamic worker management
];
let implemented_count = implemented_endpoints
.iter()
.filter(|(_, _, impl_status)| *impl_status)
.count();
let total_count = implemented_endpoints.len();
// We've implemented 10 out of 11 endpoints (register is not needed for Phase 1/2)
assert_eq!(implemented_count, 10);
assert_eq!(total_count, 11);
let missing: Vec<_> = implemented_endpoints
.iter()
.filter(|(_, _, impl_status)| !impl_status)
.map(|(endpoint, method, _)| format!("{} {}", method, endpoint))
.collect();
assert_eq!(missing, vec!["POST /register"]);
}
#[test]
fn test_large_batch_bootstrap_injection() {
// This simulates the bench_one_batch_server.py scenario
let large_batch_sizes = vec![1024, 4096, 8192];
for batch_size in large_batch_sizes {
let start = std::time::Instant::now();
let mut large_batch_request = json!({
"input_ids": vec![vec![1, 2, 3, 4]; batch_size],
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 16,
},
"stream": true
});
let prefill_worker: Box<dyn Worker> = Box::new(
BasicWorkerBuilder::new("http://prefill:8080")
.worker_type(WorkerType::Prefill {
bootstrap_port: Some(9000),
})
.api_key("test_api_key")
.build(),
);
let bootstrap_port = match prefill_worker.worker_type() {
WorkerType::Prefill { bootstrap_port } => bootstrap_port,
_ => &None,
};
let hostname = prefill_worker.bootstrap_host();
large_batch_request["bootstrap_host"] = json!(vec![hostname; batch_size]);
large_batch_request["bootstrap_port"] = json!(vec![bootstrap_port; batch_size]);
large_batch_request["bootstrap_room"] = json!((0..batch_size)
.map(|_| rand::random::<u64>())
.collect::<Vec<_>>());
let elapsed = start.elapsed();
assert_eq!(
large_batch_request["bootstrap_host"]
.as_array()
.unwrap()
.len(),
batch_size
);
assert_eq!(
large_batch_request["bootstrap_port"]
.as_array()
.unwrap()
.len(),
batch_size
);
assert_eq!(
large_batch_request["bootstrap_room"]
.as_array()
.unwrap()
.len(),
batch_size
);
// Bootstrap injection should be reasonably fast even for large batches
println!(
"Bootstrap injection for batch_size {} took {:?}",
batch_size, elapsed
);
assert!(
elapsed.as_millis() < 1000,
"Bootstrap injection took too long for batch size {}",
batch_size
);
}
}
#[test]
fn test_payload_size_calculation() {
let test_cases = vec![
(1, 1024, 16), // Small batch
(16, 1024, 16), // Medium batch
(64, 1024, 16), // Large batch
(8192, 4096, 5), // Benchmark scenario
];
for (batch_size, input_len, _output_len) in test_cases {
// Estimate payload size (rough calculation)
// Each token is ~4 bytes (i32), plus JSON overhead
let tokens_size = batch_size * input_len * 4; // 4 bytes per token
let json_overhead = batch_size * 100; // ~100 bytes overhead per request
let total_size = tokens_size + json_overhead;
println!(
"Batch size: {}, Input len: {}, Estimated payload: {} MB",
batch_size,
input_len,
total_size / (1024 * 1024)
);
// For the benchmark case (8192, 4096), this should be ~134 MB
if batch_size == 8192 && input_len == 4096 {
assert!(
total_size > 100 * 1024 * 1024,
"Benchmark payload should be > 100MB"
);
assert!(
total_size < 200 * 1024 * 1024,
"Benchmark payload should be < 200MB"
);
}
}
}
#[test]
fn test_policy_type_to_pd_selection_policy_mapping() {
let pd_policy_count = 3; // Random, PowerOfTwo, CacheAware
assert_eq!(
pd_policy_count, 3,
"PDSelectionPolicy should have exactly 3 variants"
);
let _random = PDSelectionPolicy::Random;
let _po2 = PDSelectionPolicy::PowerOfTwo;
let _cache_aware = PDSelectionPolicy::CacheAware {
cache_threshold: 0.5,
balance_abs_threshold: 32,
balance_rel_threshold: 1.1,
};
}
}

View File

@@ -0,0 +1,139 @@
//! Worker management integration tests
//!
//! Tests for dynamic worker add/remove operations via management API.
//! The actual worker management API uses:
//! - POST /workers - create a worker
//! - GET /workers - list workers
//! - DELETE /workers/{worker_id} - remove a worker
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use serde_json::json;
use tower::ServiceExt;
use crate::common::{AppTestContext, TestRouterConfig, TestWorkerConfig};
#[cfg(test)]
mod worker_management_tests {
use super::*;
/// Test listing workers via API
#[tokio::test]
async fn test_list_workers() {
let config = TestRouterConfig::round_robin(3900);
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::healthy(19900),
TestWorkerConfig::healthy(19901),
],
)
.await;
let app = ctx.create_app().await;
// List workers via GET /workers
let req = Request::builder()
.method("GET")
.uri("/workers")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"GET /workers should return OK"
);
ctx.shutdown().await;
}
/// Test that routing continues to work with multiple workers
#[tokio::test]
async fn test_routing_with_multiple_workers() {
let config = TestRouterConfig::round_robin(3901);
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::healthy(19902),
TestWorkerConfig::healthy(19903),
],
)
.await;
let app = ctx.create_app().await;
let mut success_count = 0;
// Verify routing distributes across workers
for i in 0..20 {
let payload = json!({
"text": format!("Test request {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
assert_eq!(
success_count, 20,
"All requests should succeed with multiple workers"
);
ctx.shutdown().await;
}
/// Test that requests continue to work during worker operations
#[tokio::test]
async fn test_requests_during_worker_changes() {
let config = TestRouterConfig::round_robin(3902);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(19904)]).await;
let app = ctx.create_app().await;
// Send requests and verify they succeed
let mut success_count = 0;
for i in 0..10 {
let payload = json!({
"text": format!("Request during changes {}", i),
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success_count += 1;
}
}
assert_eq!(
success_count, 10,
"All requests should succeed during normal operation"
);
ctx.shutdown().await;
}
}