//! Live API integration tests against real OpenAI endpoints. //! //! All tests are `#[ignore]` so they never run in CI or default `cargo test`. //! //! Run manually: //! ```sh //! OPENAI_API_KEY=sk-... cargo test --test live_api -- --ignored --test-threads=1 //! ``` use anyllm_proxy::config::{self, Config}; use anyllm_proxy::server::routes; use serde_json::{json, Value}; use tokio::net::TcpListener; /// Build a Config targeting the real OpenAI Chat Completions API. /// Reads OPENAI_API_KEY from the environment; panics if absent. fn test_config() -> Config { let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY must be set for live tests"); Config { backend: config::BackendKind::OpenAI, openai_api_key: api_key.clone(), openai_base_url: "https://api.openai.com".to_string(), listen_port: 0, model_mapping: config::ModelMapping { big_model: "gpt-4o-mini".to_string(), small_model: "gpt-4o-mini".to_string(), }, tls: config::TlsConfig::default(), backend_auth: config::BackendAuth::BearerToken(api_key), log_bodies: false, redact_secrets: false, anthropic_thinking_repair: false, pxpipe_compress: false, expose_degradation_warnings: false, openai_api_format: config::OpenAIApiFormat::Chat, provider_id: None, } } /// Spawn the proxy on a random port, return the base URL (e.g. "http://127.0.0.1:12345"). async fn spawn_test_server(config: Config) -> String { let app = routes::app(config); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); format!("http://{addr}") } /// Helper: build a reqwest client with the x-api-key header. fn api_client() -> reqwest::Client { reqwest::Client::new() } // --------------------------------------------------------------------------- // a) Non-streaming text completion // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn live_openai_text() { let base = spawn_test_server(test_config()).await; let client = api_client(); let resp = client .post(format!("{base}/v1/messages")) .header("x-api-key", "test-key") .header("content-type", "application/json") .json(&json!({ "model": "claude-sonnet-4-6", "max_tokens": 50, "messages": [{"role": "user", "content": "Say hello in exactly one word."}] })) .send() .await .expect("request failed"); assert_eq!(resp.status(), 200, "expected 200, got {}", resp.status()); let body: Value = resp.json().await.expect("response is not valid JSON"); assert_eq!(body["type"], "message", "type must be 'message'"); assert_eq!(body["role"], "assistant", "role must be 'assistant'"); assert_eq!( body["stop_reason"], "end_turn", "stop_reason must be 'end_turn'" ); // Content array must be non-empty with text. let content = body["content"].as_array().expect("content must be array"); assert!(!content.is_empty(), "content array must not be empty"); let first = &content[0]; assert_eq!(first["type"], "text"); let text = first["text"].as_str().expect("text must be a string"); assert!(!text.is_empty(), "text must not be empty"); // Usage must have positive token counts. let usage = &body["usage"]; assert!( usage["input_tokens"].as_u64().unwrap_or(0) > 0, "input_tokens must be > 0" ); assert!( usage["output_tokens"].as_u64().unwrap_or(0) > 0, "output_tokens must be > 0" ); } // --------------------------------------------------------------------------- // b) Streaming SSE // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn live_openai_streaming() { let base = spawn_test_server(test_config()).await; let client = api_client(); let resp = client .post(format!("{base}/v1/messages")) .header("x-api-key", "test-key") .header("content-type", "application/json") .json(&json!({ "model": "claude-sonnet-4-6", "max_tokens": 60, "stream": true, "messages": [{"role": "user", "content": "Say hello in exactly one word."}] })) .send() .await .expect("request failed"); assert_eq!(resp.status(), 200); let ct = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( ct.contains("text/event-stream"), "expected text/event-stream, got: {ct}" ); // Read the full SSE body and parse event types. let body = resp.text().await.expect("failed to read SSE body"); let event_types: Vec<&str> = body .lines() .filter_map(|line| line.strip_prefix("event: ")) .collect(); assert!( !event_types.is_empty(), "no SSE events found in response body" ); assert_eq!( event_types.first().copied(), Some("message_start"), "first event must be message_start" ); assert_eq!( event_types.last().copied(), Some("message_stop"), "last event must be message_stop" ); // At least one content_block_delta with text. let has_delta = event_types.contains(&"content_block_delta"); assert!(has_delta, "expected at least one content_block_delta event"); // Parse the data lines for content_block_delta to confirm text is present. let mut found_text = false; let mut lines_iter = body.lines().peekable(); while let Some(line) = lines_iter.next() { if line == "event: content_block_delta" { if let Some(data_line) = lines_iter.next() { if let Some(json_str) = data_line.strip_prefix("data: ") { if let Ok(val) = serde_json::from_str::(json_str) { if val["delta"]["type"] == "text_delta" && val["delta"]["text"].as_str().is_some() { found_text = true; break; } } } } } } assert!( found_text, "no text_delta found in content_block_delta events" ); } // --------------------------------------------------------------------------- // c) Tool call // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn live_openai_tool_call() { let base = spawn_test_server(test_config()).await; let client = api_client(); let resp = client .post(format!("{base}/v1/messages")) .header("x-api-key", "test-key") .header("content-type", "application/json") .json(&json!({ "model": "claude-sonnet-4-6", "max_tokens": 200, "tools": [{ "name": "get_weather", "description": "Get the current weather in a given location.", "input_schema": { "type": "object", "properties": { "location": { "type": "string", "description": "City and state, e.g. San Francisco, CA" } }, "required": ["location"] } }], "messages": [{ "role": "user", "content": "What is the weather in San Francisco?" }] })) .send() .await .expect("request failed"); assert_eq!(resp.status(), 200, "expected 200, got {}", resp.status()); let body: Value = resp.json().await.expect("response is not valid JSON"); assert_eq!(body["type"], "message"); // Find a tool_use content block. let content = body["content"].as_array().expect("content must be array"); let tool_use = content .iter() .find(|b| b["type"] == "tool_use") .expect("expected a tool_use content block in response"); let id = tool_use["id"].as_str().expect("tool_use must have id"); assert!(!id.is_empty(), "tool_use id must not be empty"); let name = tool_use["name"].as_str().expect("tool_use must have name"); assert_eq!(name, "get_weather", "tool name must be get_weather"); let input = &tool_use["input"]; assert!(input.is_object(), "tool_use input must be an object"); assert!( input["location"].as_str().is_some(), "tool input should contain 'location'" ); } // --------------------------------------------------------------------------- // d) Authentication error // --------------------------------------------------------------------------- #[tokio::test] #[ignore] async fn live_openai_error() { // Build config with an invalid API key. let bad_key = "sk-invalid-key-for-testing"; let config = Config { backend: config::BackendKind::OpenAI, openai_api_key: bad_key.to_string(), openai_base_url: "https://api.openai.com".to_string(), listen_port: 0, model_mapping: config::ModelMapping { big_model: "gpt-4o-mini".to_string(), small_model: "gpt-4o-mini".to_string(), }, tls: config::TlsConfig::default(), backend_auth: config::BackendAuth::BearerToken(bad_key.to_string()), log_bodies: false, redact_secrets: false, anthropic_thinking_repair: false, pxpipe_compress: false, expose_degradation_warnings: false, openai_api_format: config::OpenAIApiFormat::Chat, provider_id: None, }; let base = spawn_test_server(config).await; let client = api_client(); let resp = client .post(format!("{base}/v1/messages")) .header("x-api-key", "test-key") .header("content-type", "application/json") .json(&json!({ "model": "claude-sonnet-4-6", "max_tokens": 10, "messages": [{"role": "user", "content": "Hello"}] })) .send() .await .expect("request failed"); assert_eq!(resp.status(), 401, "expected 401, got {}", resp.status()); let body: Value = resp.json().await.expect("response is not valid JSON"); assert_eq!(body["type"], "error", "response type must be 'error'"); assert!( body["error"]["type"].as_str().is_some(), "error.type must be present" ); assert!( body["error"]["message"].as_str().is_some(), "error.message must be present" ); }