765 lines
25 KiB
Rust
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);
|
|
}
|