Add initial llm-bridge project
This commit is contained in:
@@ -0,0 +1,764 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in New Issue
Block a user