#![cfg(feature = "proxy")]
use base64::Engine;
use reqwest::Client;
use serde_json::{json, Value};
use std::{
process::{Child, Command, Stdio},
time::Duration,
};
struct ProxyProcess(Child);
impl Drop for ProxyProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
async fn post(client: &Client, base: &str, path: &str, request: &Value) -> reqwest::Response {
let response = client
.post(format!("{base}{path}"))
.json(request)
.send()
.await
.unwrap();
assert!(
response.status().is_success(),
"{} {}",
request["model"],
response.status()
);
response
}
fn events(body: &str) -> Vec<Value> {
body.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(|line| serde_json::from_str(line.trim()).unwrap())
.collect()
}
fn assert_complete(events: &[Value]) {
assert!(
events.iter().all(|event| event["type"] != "error"),
"{events:?}"
);
assert_eq!(
events
.iter()
.filter(|event| event["type"] == "done")
.count(),
1,
"{events:?}"
);
assert!(
events.iter().any(
|event| event["type"] == "usage" && event["total_tokens"].as_u64().unwrap_or(0) > 0
),
"{events:?}"
);
}
#[tokio::test]
#[ignore = "requires a saved ChatGPT login and consumes subscription usage"]
async fn chatgpt_server_mode() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let mut server = ProxyProcess(
Command::new(env!("CARGO_BIN_EXE_llmshim"))
.arg("proxy")
.env("LLMSHIM_HOST", "127.0.0.1")
.env("LLMSHIM_PORT", port.to_string())
.env("OPENAI_API_KEY", "server-smoke-no-api-key")
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.unwrap(),
);
let client = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(180))
.build()
.unwrap();
let base = format!("http://127.0.0.1:{port}");
let mut healthy = false;
for _ in 0..100 {
assert!(
server.0.try_wait().unwrap().is_none(),
"proxy exited before becoming healthy"
);
if let Ok(response) = client.get(format!("{base}/health")).send().await {
let health: Value = response.json().await.unwrap();
assert_eq!(health["status"], "ok");
assert!(
health["providers"]
.as_array()
.unwrap()
.contains(&json!("chatgpt")),
"run `llmshim login chatgpt` first"
);
healthy = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(healthy, "proxy did not become healthy");
let models: Value = client
.get(format!("{base}/v1/models"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let actual: Vec<_> = models["models"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["provider"] == "chatgpt")
.map(|m| m["id"].as_str().unwrap())
.collect();
let expected = [
"chatgpt/gpt-6-astra",
"chatgpt/gpt-5.6-sol",
"chatgpt/gpt-5.6-terra",
"chatgpt/gpt-5.6-luna",
];
assert_eq!(actual, expected);
println!("PASS /health and /v1/models (exactly four ChatGPT models)");
for model in expected {
let request = json!({"model": model, "messages": [{"role": "user", "content": "Reply with only pong."}],
"config": {"reasoning_effort": "low", "max_tokens": 10}});
let response: Value = post(&client, &base, "/v1/chat", &request)
.await
.json()
.await
.unwrap();
assert_eq!(response["provider"], "chatgpt", "{response}");
assert_eq!(response["model"], model, "{response}");
assert!(
response["message"]["content"]
.as_str()
.unwrap()
.to_lowercase()
.contains("pong"),
"{response}"
);
assert!(response["usage"]["total_tokens"].as_u64().unwrap() > 0);
println!("PASS {model} POST /v1/chat");
let response = post(&client, &base, "/v1/chat/stream", &request).await;
assert!(response.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("text/event-stream"));
let chunks = events(&response.text().await.unwrap());
assert_complete(&chunks);
let text: String = chunks
.iter()
.filter(|c| c["type"] == "content")
.filter_map(|c| c["text"].as_str())
.collect();
assert!(text.to_lowercase().contains("pong"), "{chunks:?}");
println!("PASS {model} POST /v1/chat/stream");
}
let invalid = client
.post(format!("{base}/v1/chat"))
.json(&json!({"model": "chatgpt/gpt-5.4", "messages": []}))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), 400);
println!("PASS older ChatGPT model rejected with HTTP 400");
let mut tool_request = json!({"model": "chatgpt/gpt-6-astra", "messages": [{"role": "user", "content": "Use lookup_weather for Paris. After receiving the result, repeat the returned condition."}],
"config": {"reasoning_effort": "max"}, "provider_config": {
"tools": [{"type": "function", "function": {"name": "lookup_weather", "description": "Look up weather for a city.",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], "additionalProperties": false}}}],
"tool_choice": {"type": "function", "function": {"name": "lookup_weather"}}
}});
let response: Value = post(&client, &base, "/v1/chat", &tool_request)
.await
.json()
.await
.unwrap();
let call = response["message"]["tool_calls"][0].clone();
assert_eq!(call["function"]["name"], "lookup_weather", "{response}");
let args: Value =
serde_json::from_str(call["function"]["arguments"].as_str().unwrap()).unwrap();
assert_eq!(args["city"], "Paris");
println!("PASS Astra max reasoning and forced function call");
let mut streamed_tool_request = tool_request.clone();
streamed_tool_request["config"]["reasoning_effort"] = json!("low");
let chunks = events(
&post(&client, &base, "/v1/chat/stream", &streamed_tool_request)
.await
.text()
.await
.unwrap(),
);
assert_complete(&chunks);
let tool = chunks
.iter()
.find(|c| c["type"] == "tool_call")
.expect("missing streamed tool call");
assert_eq!(tool["name"], "lookup_weather");
let args: Value = serde_json::from_str(tool["arguments"].as_str().unwrap())
.expect("streamed function arguments must be complete JSON");
assert_eq!(args["city"], "Paris");
println!("PASS streamed function call with complete arguments");
let messages = tool_request["messages"].as_array_mut().unwrap();
messages.push(response["message"].clone());
messages.push(json!({"role": "tool", "tool_call_id": call["id"], "content": "Sunny"}));
tool_request["config"]["reasoning_effort"] = json!("low");
tool_request["provider_config"]["tool_choice"] = json!("none");
tool_request["stream"] = json!(true);
let chunks = events(
&post(&client, &base, "/v1/chat", &tool_request)
.await
.text()
.await
.unwrap(),
);
assert_complete(&chunks);
let text: String = chunks
.iter()
.filter(|c| c["type"] == "content")
.filter_map(|c| c["text"].as_str())
.collect();
assert!(text.to_lowercase().contains("sunny"), "{chunks:?}");
println!("PASS tool-result round trip through POST /v1/chat with stream:true");
let image = format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD
.encode(include_bytes!("fixtures/chatgpt-red.png"))
);
let request = json!({"model": "chatgpt/gpt-6-astra", "config": {"reasoning_effort": "low"},
"messages": [{"role": "user", "content": [
{"type": "text", "text": "What is the dominant color in this image? Answer with one color word."},
{"type": "image_url", "image_url": {"url": image}}
]}]});
let response: Value = post(&client, &base, "/v1/chat", &request)
.await
.json()
.await
.unwrap();
assert!(
response["message"]["content"]
.as_str()
.unwrap()
.to_lowercase()
.contains("red"),
"{response}"
);
println!("PASS Astra image input through server mode");
}