Files
llm-bridge/tests/api_integration.rs
T
bruno 7b5aa6fcc9
CI / fmt, clippy and tests (ubuntu-latest) (push) Successful in 21m0s
CI / browser end-to-end (Chromium + fixture) (push) Skipped
CI / fmt, clippy and tests (windows-latest) (push) Canceled after 0s
Add initial llm-bridge project
2026-09-18 18:01:07 -04:00

765 lines
25 KiB
Rust

//! HTTP integration tests against the mock backend: no browser, no network.
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use llm_bridge::config::{CliOverrides, GlobalConfig, ScreenshotMode};
use llm_bridge::conversation::ThreadStore;
use llm_bridge::paths::Layout;
use llm_bridge::providers::{AccountInfo, ChatBackend, MockBackend};
use llm_bridge::server::{router, AppState, LogBuffer};
use serde_json::{json, Value};
use tower::ServiceExt;
struct Harness {
app: Router,
store: Arc<ThreadStore>,
state: AppState,
}
/// The two accounts of the mock ChatGPT provider.
fn mock_accounts() -> Vec<AccountInfo> {
vec![
AccountInfo {
id: "perso".to_string(),
email: Some("[email protected]".to_string()),
label: None,
default: true,
profile: "profiles/chatgpt/accounts/perso/user-data-dir".to_string(),
},
AccountInfo {
id: "pro".to_string(),
email: Some("[email protected]".to_string()),
label: Some("Work".to_string()),
default: false,
profile: "profiles/chatgpt/accounts/pro/user-data-dir".to_string(),
},
]
}
fn mock_backend(accounts: bool) -> MockBackend {
let backend = MockBackend::new(
vec![("chatgpt", "gpt-4o"), ("claude", "claude-sonnet-4")],
Duration::from_millis(1),
);
if accounts {
backend.with_accounts("chatgpt", mock_accounts())
} else {
backend
}
}
fn harness_with(config: GlobalConfig) -> Harness {
harness_with_backend(config, mock_backend(false))
}
fn harness_with_backend(config: GlobalConfig, backend: MockBackend) -> Harness {
let backend: Arc<dyn ChatBackend> = Arc::new(backend);
let store = Arc::new(ThreadStore::in_memory());
let layout = Layout::rooted_at(std::env::temp_dir().join("llm-gateway-tests"));
let state = AppState::new(
backend,
Arc::clone(&store),
layout,
Arc::new(ArcSwap::from_pointee(config)),
CliOverrides::default(),
LogBuffer::new(32),
);
Harness {
app: router(state.clone()),
store,
state,
}
}
fn harness() -> Harness {
harness_with(GlobalConfig::default())
}
/// A harness whose ChatGPT provider declares two accounts.
fn harness_with_accounts(config: GlobalConfig) -> Harness {
harness_with_backend(config, mock_backend(true))
}
async fn send(app: &Router, request: Request<Body>) -> (StatusCode, HeaderMap, String) {
let response = app
.clone()
.oneshot(request)
.await
.expect("router call failed");
let status = response.status();
let headers = response.headers().clone();
let bytes = response
.into_body()
.collect()
.await
.expect("body")
.to_bytes();
(status, headers, String::from_utf8_lossy(&bytes).to_string())
}
async fn get_json(app: &Router, path: &str) -> (StatusCode, Value) {
let request = Request::builder().uri(path).body(Body::empty()).unwrap();
let (status, _, body) = send(app, request).await;
(status, serde_json::from_str(&body).unwrap_or(Value::Null))
}
fn post_chat(body: Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
#[tokio::test]
async fn health_and_models_describe_every_provider() {
let harness = harness();
let (status, health) = get_json(&harness.app, "/health").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(health["status"], "ok");
assert_eq!(health["providers"].as_array().unwrap().len(), 2);
let (status, models) = get_json(&harness.app, "/v1/models").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(models["object"], "list");
let ids: Vec<String> = models["data"]
.as_array()
.unwrap()
.iter()
.map(|entry| entry["id"].as_str().unwrap().to_string())
.collect();
assert!(ids.contains(&"chatgpt".to_string()));
assert!(ids.contains(&"chatgpt/gpt-4o".to_string()));
assert!(ids.contains(&"claude/claude-sonnet-4".to_string()));
}
#[tokio::test]
async fn a_chat_completion_returns_the_openai_shape_and_a_conversation_id() {
let harness = harness();
let (status, headers, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hello"}],
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["object"], "chat.completion");
assert_eq!(value["model"], "chatgpt");
assert_eq!(value["choices"][0]["message"]["role"], "assistant");
assert_eq!(value["choices"][0]["finish_reason"], "stop");
let content = value["choices"][0]["message"]["content"].as_str().unwrap();
assert!(!content.is_empty());
assert!(value["usage"]["total_tokens"].as_u64().unwrap() >= 1);
assert!(value["id"].as_str().unwrap().starts_with("chatcmpl-"));
let conversation = headers
.get("x-conversation-id")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(conversation.starts_with("fp-"), "{conversation}");
assert!(headers.get("x-trace-id").is_some());
// The thread mapping was persisted for the next turn.
let record = harness
.store
.get(&conversation)
.await
.expect("thread recorded");
assert_eq!(record.provider, "chatgpt");
assert!(record.web_url.is_some());
}
#[tokio::test]
async fn the_same_conversation_is_reused_when_the_header_is_repeated() {
let harness = harness();
let request = |text: &str| {
post_chat(json!({
"model": "chatgpt",
"messages": [
{"role": "user", "content": "opening"},
{"role": "assistant", "content": "answer"},
{"role": "user", "content": text},
],
}))
};
let (_, headers, _) = send(&harness.app, request("second")).await;
let conversation = headers
.get("x-conversation-id")
.unwrap()
.to_str()
.unwrap()
.to_string();
let (status, _, _) = send(&harness.app, request("third")).await;
assert_eq!(status, StatusCode::OK);
let record = harness.store.get(&conversation).await.unwrap();
assert_eq!(
record.turns, 2,
"the second call must reuse the stored thread"
);
}
#[tokio::test]
async fn streaming_emits_sse_chunks_and_terminates_with_done() {
let harness = harness();
let (status, headers, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hello"}],
"stream": true,
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let content_type = headers.get("content-type").unwrap().to_str().unwrap();
assert!(
content_type.starts_with("text/event-stream"),
"{content_type}"
);
assert!(body.contains("chat.completion.chunk"), "{body}");
assert!(body.contains(r#""role":"assistant""#), "{body}");
assert!(body.contains("data: [DONE]"), "{body}");
let deltas: String = body
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.filter(|payload| *payload != "[DONE]")
.filter_map(|payload| serde_json::from_str::<Value>(payload).ok())
.filter_map(|chunk| {
chunk["choices"][0]["delta"]["content"]
.as_str()
.map(str::to_string)
})
.collect();
assert!(!deltas.is_empty());
assert!(deltas.contains("mock backend"), "{deltas}");
}
#[tokio::test]
async fn the_conversation_header_wins_over_the_fingerprint() {
let harness = harness();
let request = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.header("x-conversation-id", "client-thread-42")
.body(Body::from(
json!({"model": "chatgpt", "messages": [{"role": "user", "content": "hi"}]})
.to_string(),
))
.unwrap();
let (status, headers, _) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
headers.get("x-conversation-id").unwrap(),
"client-thread-42"
);
assert!(harness.store.get("client-thread-42").await.is_some());
}
#[tokio::test]
async fn unsupported_parameters_are_rejected_with_a_clear_error() {
let harness = harness();
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "f"}}],
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["error"]["code"], "unsupported_parameter");
assert_eq!(value["error"]["param"], "tools");
assert_eq!(value["error"]["type"], "invalid_request_error");
// n = 0 is a client mistake, unlike n > 1 which is now supported.
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hi"}],
"n": 0,
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["error"]["param"], "n");
}
#[tokio::test]
async fn several_completions_come_back_as_several_choices() {
let harness = harness();
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hello"}],
"n": 3,
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
let choices = value["choices"].as_array().unwrap();
assert_eq!(choices.len(), 3);
for (index, choice) in choices.iter().enumerate() {
assert_eq!(choice["index"], index as u64);
assert_eq!(choice["message"]["role"], "assistant");
assert_eq!(choice["finish_reason"], "stop");
assert!(!choice["message"]["content"].as_str().unwrap().is_empty());
}
assert!(value["usage"]["total_tokens"].as_u64().unwrap() >= 1);
let warnings = value["x_gateway_warnings"].as_array().unwrap();
assert!(
warnings.iter().any(|w| w
.as_str()
.unwrap()
.contains("independent web conversations")),
"{warnings:?}"
);
}
#[tokio::test]
async fn n_is_clamped_to_the_configured_maximum() {
let mut config = GlobalConfig::default();
config.capture.max_variants = 2;
let harness = harness_with(config);
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hello"}],
"n": 9,
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["choices"].as_array().unwrap().len(), 2);
let warnings = value["x_gateway_warnings"].as_array().unwrap();
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap().contains("clamped to 2")),
"{warnings:?}"
);
}
#[tokio::test]
async fn streamed_choices_are_tagged_with_their_index() {
let harness = harness();
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hello"}],
"stream": true,
"n": 2,
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let chunks: Vec<Value> = body
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.filter(|payload| *payload != "[DONE]")
.filter_map(|payload| serde_json::from_str::<Value>(payload).ok())
.collect();
assert!(body.contains("data: [DONE]"), "{body}");
let indexes: Vec<u64> = chunks
.iter()
.flat_map(|chunk| chunk["choices"].as_array().cloned().unwrap_or_default())
.filter_map(|choice| choice["index"].as_u64())
.collect();
assert!(indexes.contains(&0), "{indexes:?}");
assert!(indexes.contains(&1), "{indexes:?}");
let stops = chunks
.iter()
.flat_map(|chunk| chunk["choices"].as_array().cloned().unwrap_or_default())
.filter(|choice| choice["finish_reason"] == "stop")
.count();
assert_eq!(stops, 2, "each choice must finish: {body}");
}
#[tokio::test]
async fn an_account_can_be_selected_in_the_model_string() {
let harness = harness_with_accounts(GlobalConfig::default());
// The declared accounts show up in /v1/models and /health.
let (_, models) = get_json(&harness.app, "/v1/models").await;
let ids: Vec<String> = models["data"]
.as_array()
.unwrap()
.iter()
.map(|entry| entry["id"].as_str().unwrap().to_string())
.collect();
assert!(ids.contains(&"chatgpt@perso".to_string()), "{ids:?}");
assert!(ids.contains(&"chatgpt@pro".to_string()), "{ids:?}");
let (_, health) = get_json(&harness.app, "/health").await;
let chatgpt = health["providers"]
.as_array()
.unwrap()
.iter()
.find(|provider| provider["name"] == "chatgpt")
.unwrap();
assert_eq!(chatgpt["accounts"].as_array().unwrap().len(), 2);
// By account id, and by e-mail address.
for model in ["chatgpt@pro", "[email protected]@gmail.com"] {
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": model,
"messages": [{"role": "user", "content": "hello"}],
})),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["model"], model);
}
}
#[tokio::test]
async fn an_unknown_account_is_a_404_listing_the_known_ones() {
let harness = harness_with_accounts(GlobalConfig::default());
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt@nobody",
"messages": [{"role": "user", "content": "hello"}],
})),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["error"]["code"], "account_not_found");
let message = value["error"]["message"].as_str().unwrap();
assert!(message.contains("no account 'nobody'"), "{message}");
assert!(message.contains("perso"), "{message}");
assert!(
!message.contains("does not match any configured provider"),
"the message must name the account problem: {message}"
);
}
#[tokio::test]
async fn two_accounts_never_share_a_thread() {
let harness = harness_with_accounts(GlobalConfig::default());
let request = |model: &str| {
post_chat(json!({
"model": model,
"messages": [{"role": "user", "content": "hello"}],
}))
};
let (_, first, _) = send(&harness.app, request("chatgpt@perso")).await;
let (_, second, _) = send(&harness.app, request("chatgpt@pro")).await;
let first = first
.get("x-conversation-id")
.unwrap()
.to_str()
.unwrap()
.to_string();
let second = second
.get("x-conversation-id")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_ne!(first, second, "the account is part of the thread identity");
}
#[tokio::test]
async fn an_unknown_model_is_a_404() {
let harness = harness();
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "gemini",
"messages": [{"role": "user", "content": "hi"}],
})),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["error"]["code"], "model_not_found");
assert!(value["error"]["message"]
.as_str()
.unwrap()
.contains("chatgpt"));
}
#[tokio::test]
async fn ignored_parameters_are_reported_in_the_response() {
let harness = harness();
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hi"}],
"temperature": 0.1,
"max_tokens": 10,
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
let warnings = value["x_gateway_warnings"].as_array().unwrap();
assert!(warnings
.iter()
.any(|w| w.as_str().unwrap().contains("temperature")));
assert!(warnings
.iter()
.any(|w| w.as_str().unwrap().contains("max_tokens")));
}
#[tokio::test]
async fn warnings_can_be_switched_off() {
let mut config = GlobalConfig::default();
config.server.include_warnings = false;
let harness = harness_with(config);
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hi"}],
"top_p": 0.5,
})),
)
.await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
assert!(value.get("x_gateway_warnings").is_none());
}
#[tokio::test]
async fn the_legacy_completions_endpoint_is_supported() {
let harness = harness();
let request = Request::builder()
.method("POST")
.uri("/v1/completions")
.header("content-type", "application/json")
.body(Body::from(
json!({"model": "chatgpt", "prompt": "say ok"}).to_string(),
))
.unwrap();
let (status, headers, body) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["object"], "text_completion");
assert!(!value["choices"][0]["text"].as_str().unwrap().is_empty());
assert!(headers.get("x-conversation-id").is_some());
let request = Request::builder()
.method("POST")
.uri("/v1/completions")
.header("content-type", "application/json")
.body(Body::from(
json!({"model": "chatgpt", "prompt": "say ok", "stream": true}).to_string(),
))
.unwrap();
let (status, _, body) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("unsupported_parameter"));
}
#[tokio::test]
async fn provider_status_and_validation_are_exposed() {
let harness = harness();
let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt/status").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["state"], "available");
assert_eq!(body["provider"], "chatgpt");
let request = Request::builder()
.method("POST")
.uri("/v1/providers/chatgpt/validate")
.body(Body::empty())
.unwrap();
let (status, _, body) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["status"], "ok");
assert!(!value["steps"].as_array().unwrap().is_empty());
let (status, body) = get_json(&harness.app, "/v1/providers/nope/status").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "model_not_found");
}
#[tokio::test]
async fn provider_status_and_validation_can_name_an_account() {
let harness = harness_with_accounts(GlobalConfig::default());
// A named account is probed, and the answer says which one.
let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt@pro/status").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["state"], "available");
assert_eq!(body["provider"], "chatgpt@pro");
// The bare provider name means "its default account", and says so rather
// than leaving the caller to guess which profile answered.
let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt/status").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["provider"], "chatgpt@perso");
let (status, body) = post_account_validate(&harness, "chatgpt@pro").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["ok"], true);
assert_eq!(body["provider"], "chatgpt@pro");
// The status route keeps working for a provider that declares no account.
let (status, body) = get_json(&harness.app, "/v1/providers/claude/status").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["provider"], "claude");
}
#[tokio::test]
async fn an_unknown_account_is_a_404_on_the_status_routes() {
let harness = harness_with_accounts(GlobalConfig::default());
let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt@nobody/status").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "account_not_found");
assert!(body["error"]["message"].as_str().unwrap().contains("perso"));
let (status, body) = post_account_validate(&harness, "chatgpt@nobody").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "account_not_found");
// A provider that declares no account refuses a named one instead of
// silently probing its only profile.
let (status, body) = get_json(&harness.app, "/v1/providers/claude@perso/status").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "account_not_found");
}
/// POST /v1/providers/{owner}/validate and parse the JSON body.
async fn post_account_validate(harness: &Harness, owner: &str) -> (StatusCode, Value) {
let request = Request::builder()
.method("POST")
.uri(format!("/v1/providers/{owner}/validate"))
.body(Body::empty())
.unwrap();
let (status, _, body) = send(&harness.app, request).await;
(status, serde_json::from_str(&body).unwrap_or(Value::Null))
}
#[tokio::test]
async fn reload_reports_the_configuration_diff() {
let harness = harness();
let request = Request::builder()
.method("POST")
.uri("/v1/admin/reload")
.body(Body::empty())
.unwrap();
let (status, _, body) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::OK);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["config"], "reloaded");
}
#[tokio::test]
async fn an_api_key_protects_the_v1_routes_only() {
let mut config = GlobalConfig::default();
config.server.api_key = "s3cret".to_string();
let harness = harness_with(config);
let (status, _, body) = send(
&harness.app,
post_chat(json!({
"model": "chatgpt",
"messages": [{"role": "user", "content": "hi"}],
})),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["error"]["code"], "invalid_api_key");
let request = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.header("authorization", "Bearer s3cret")
.body(Body::from(
json!({"model": "chatgpt", "messages": [{"role": "user", "content": "hi"}]})
.to_string(),
))
.unwrap();
let (status, _, _) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::OK);
// /health stays open so that monitors and the dashboard can reach it.
let (status, _) = get_json(&harness.app, "/health").await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_malformed_body_is_a_400_with_the_openai_error_shape() {
let harness = harness();
let request = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from("{not json"))
.unwrap();
let (status, _, body) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let value: Value = serde_json::from_str(&body).unwrap();
assert_eq!(value["error"]["code"], "invalid_request");
}
#[tokio::test]
async fn the_dashboard_is_served_and_exposes_metrics() {
let harness = harness();
let request = Request::builder()
.uri("/dashboard")
.body(Body::empty())
.unwrap();
let (status, _, body) = send(&harness.app, request).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("llm-gateway"));
let (status, metrics) = get_json(&harness.app, "/metrics").await;
assert_eq!(status, StatusCode::OK);
assert!(metrics["requests_total"].is_number());
// The screenshot policy is only reachable through the configuration.
let config = harness.state.config();
assert_eq!(config.debug.screenshots, ScreenshotMode::OnError);
}