use super::*; use crate::bootstrap::Application; use axum::{ Json, body::{Body, to_bytes}, http::Request, routing::post, }; use nyro_config::{compile::Snapshot, resources::Resources}; use serde_json::{Value, json}; use tokio_util::sync::CancellationToken; use tower::ServiceExt; fn snapshot(url: &str) -> Snapshot { let resources: Resources = serde_json::from_value(json!({"version":1, "upstreams":[{"id":"pool","kind":"llm","targets":[{"id":"a","protocol":"openai/chat-completions","base_url":url,"model":"a","weight":3},{"id":"b","protocol":"openai/chat-completions","base_url":url,"model":"b","weight":1}]}], "models":[{"id":"chat","capability":"chat","upstream":"pool","access":{"mode":"restricted"},"limits":{"token":[{"limit":10,"window":"1m"}]}}], "consumers":[{"id":"app","credentials":[{"id":"old","type":"key-auth","secret":"old-key"},{"id":"new","type":"key-auth","secret":"new-key"}],"grants":{"models":["chat"]},"limits":{"llm":{"request":[{"limit":2,"window":"1m"}]}}}] })).unwrap(); Snapshot::file(resources) } fn request(model: &str, key: &str) -> Request { Request::builder() .method("POST") .uri("/v1/chat/completions") .header("content-type", "application/json") .header("authorization", format!("Bearer {key}")) .body(Body::from( json!({"model":model,"messages":[{"role":"user","content":"Hello"}]}).to_string(), )) .unwrap() } async fn read(response: Response) -> Value { serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap() } async fn upstream() -> (String, tokio::task::JoinHandle<()>) { let app = Router::new().route("/v1/chat/completions", post(|Json(v): Json| async move { Json(json!({"id":v["model"],"object":"chat.completion","created":1,"model":v["model"],"choices":[{"index":0,"message":{"role":"assistant","content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}})) })); let tcp = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}/v1", tcp.local_addr().unwrap()); ( url, tokio::spawn(async move { axum::serve(tcp, app).await.unwrap(); }), ) } #[tokio::test] async fn consumer_rotation_model_rename_and_failed_candidate_keep_actual_usage() { let (url, task) = upstream().await; let snapshot = snapshot(&url); let application = Application::new(8).unwrap(); let host = Arc::new(Host::new(Default::default())); let tracker = TaskTracker::new(); let app = router(host.clone(), tracker.clone()); assert_eq!( app.clone() .oneshot( Request::builder() .uri("/readyz") .body(Body::empty()) .unwrap() ) .await .unwrap() .status(), StatusCode::SERVICE_UNAVAILABLE ); assert!(application.apply(&host, &snapshot).await.is_ok()); let first = app .clone() .oneshot(request("chat", "old-key")) .await .unwrap(); assert_eq!(first.status(), StatusCode::OK); read(first).await; let mut invalid = snapshot.clone(); invalid.resources.models[0].upstream = "missing".into(); assert!(application.apply(&host, &invalid).await.is_err()); let renamed = nyro_control::resource::edit(&snapshot, nyro_control::resource::Kind::Models, Some("chat"), Some(json!({"id":"renamed","capability":"chat","upstream":"pool","limits":{"token":[{"limit":10,"window":"1m"}]}}))).unwrap(); assert!(application.apply(&host, &renamed).await.is_ok()); let second = app .clone() .oneshot(request("renamed", "new-key")) .await .unwrap(); assert_eq!(second.status(), StatusCode::OK); read(second).await; assert_eq!( app.clone() .oneshot(request("renamed", "old-key")) .await .unwrap() .status(), StatusCode::TOO_MANY_REQUESTS ); host.shutdown().await.unwrap(); tracker.close(); tracker.wait().await; task.abort(); } #[tokio::test] async fn aliases_share_round_robin_and_anonymous_ignores_invalid_credentials() { let (url, task) = upstream().await; let mut snapshot = snapshot(&url); snapshot.resources.models[0].access.mode = nyro_config::resources::AccessMode::Anonymous; snapshot.resources.models[0].limits = Default::default(); let mut alias = snapshot.resources.models[0].clone(); alias.id = "alias".into(); snapshot.resources.models.push(alias); snapshot .identities .models .insert("alias".into(), "models:alias".into()); let application = Application::new(8).unwrap(); let host = Arc::new(Host::new(Default::default())); assert!(application.apply(&host, &snapshot).await.is_ok()); let tracker = TaskTracker::new(); let app = router(host.clone(), tracker.clone()); let mut ids = vec![]; for model in ["chat", "alias", "chat", "alias"] { let response = app .clone() .oneshot(request(model, "wrong-key")) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); ids.push(read(response).await["id"].as_str().unwrap().to_owned()); } assert_eq!(ids.iter().filter(|v| *v == "a").count(), 3); assert_eq!(ids.iter().filter(|v| *v == "b").count(), 1); host.shutdown().await.unwrap(); tracker.close(); tracker.wait().await; task.abort(); } #[tokio::test] async fn memory_updates_activate_and_ack_only_after_host_is_ready() { let host = Arc::new(Host::new(Default::default())); let application = Arc::new(Application::new(2).unwrap()); let hub = nyro_sync::Hub::new(Snapshot::file(Resources::default())).unwrap(); let stop = CancellationToken::new(); let source = hub.clone(); let apply_host = host.clone(); let cancel = stop.clone(); let worker = tokio::spawn(async move { nyro_sync::run(&source, "embedded", cancel, |snapshot| { let app = application.clone(); let host = apply_host.clone(); async move { app.apply(&host, &snapshot.config).await } }) .await }); tokio::time::timeout(Duration::from_secs(3), async { loop { if hub.node("embedded").is_some_and(|s| s.applied.is_some()) { break; } tokio::task::yield_now().await; } }) .await .unwrap(); assert!(host.status().accepting); stop.cancel(); worker.await.unwrap().unwrap(); host.shutdown().await.unwrap(); } #[tokio::test] async fn mcp_pool_query_credentials_and_resource_request_limits_are_independent_of_llm() { let calls = Arc::new(std::sync::Mutex::new(Vec::new())); let recorded = calls.clone(); let upstream = Router::new().route("/mcp",post(move |request: Request| { let calls = recorded.clone(); async move { assert!(!request.headers().contains_key("authorization")); let query = request.uri().query().unwrap().to_owned(); let value: Value = serde_json::from_slice(&to_bytes(request.into_body(),65536).await.unwrap()).unwrap(); let result = match value["method"].as_str().unwrap() { "server/discover" => json!({"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{"tools":{}},"ttlMs":0,"cacheScope":"private"}), "tools/list" => { calls.lock().unwrap().push(query); json!({"tools":[{"name":"read","inputSchema":{"type":"object"}}]}) }, method => panic!("unexpected method {method}"), }; Json(json!({"jsonrpc":"2.0","id":value["id"],"result":result})) } })); let tcp = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}/mcp", tcp.local_addr().unwrap()); let task = tokio::spawn(async move { axum::serve(tcp, upstream).await.unwrap(); }); let resources: Resources = serde_json::from_value(json!({"version":1,"upstreams":[{"id":"mcp-pool","kind":"mcp","targets":[ {"id":"a","url":url,"transport":"streamable-http","weight":3,"auth":{"type":"key-auth","in":"query","name":"key","secret":"a+ /?"}}, {"id":"b","url":url,"transport":"streamable-http","weight":1,"auth":{"type":"key-auth","in":"query","name":"key","secret":"b"}} ]}],"mcps":[{"id":"tools","upstream":"mcp-pool","allowed_tools":["read"],"access":{"mode":"anonymous"},"limits":{"request":[{"limit":4,"window":"1m"}]}}]})).unwrap(); let application = Application::new(4).unwrap(); let host = Arc::new(Host::new(Default::default())); assert!( application .apply(&host, &Snapshot::file(resources)) .await .is_ok() ); let tracker = TaskTracker::new(); let app = router(host.clone(), tracker.clone()); for index in 0..5 { let request = Request::builder() .method("POST") .uri("/mcp/tools") .header("host", "localhost") .header("authorization", "Bearer wrong-key") .header("content-type", "application/json") .header("accept", "application/json, text/event-stream") .header("mcp-protocol-version", "2026-07-28") .header("mcp-method", "tools/list") .body(Body::from( json!({"jsonrpc":"2.0","id":index,"method":"tools/list","params":{"_meta":{ "io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientInfo":{"name":"test","version":"1"}, "io.modelcontextprotocol/clientCapabilities":{} }}}) .to_string(), )) .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); assert_eq!( response.status(), if index == 4 { StatusCode::TOO_MANY_REQUESTS } else { StatusCode::OK } ); read(response).await; } let calls = calls.lock().unwrap().clone(); assert_eq!( calls .iter() .filter(|q| q.as_str() == "key=a%2B+%2F%3F") .count(), 3 ); assert_eq!(calls.iter().filter(|q| q.as_str() == "key=b").count(), 1); host.shutdown().await.unwrap(); tracker.close(); tracker.wait().await; task.abort(); }