headroom/crates/headroom-simulators/tests/simulator_http.rs
JD Davis 2c9eb7c5f1
feat(simulators): add provider simulator service (#2014)
## Description  
Adds a Rust-only `headroom-simulators` workspace crate: a deterministic
local upstream simulator service for Headroom proxy and pipeline
validation. It supplies configurable stubs plus bottled provider-shaped
responses for supported provider/path surfaces without calling real
LLMs.

## Type of Change  
- [x] Bug fix (non-breaking change that fixes an issue)  
- [x] New feature (non-breaking change that adds functionality)  
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update  
- [ ] Performance improvement  
- [ ] Code refactoring (no functional changes)

## Changes Made  
- Added `crates/headroom-simulators` Rust crate with library and
`headroom-simulators` binary.
- Added clean domain classification for supported surfaces: Anthropic
`/v1/messages`, OpenAI chat/responses/conversations, Bedrock
invoke/stream routes, Vertex raw/stream predict, health, and generic
fallback.
- Added JSON-configured stub matching by method, path, body substring,
and JSON pointer.
- Added bottled provider-shaped JSON, SSE, and Bedrock EventStream
responses for unconfigured requests.
- Added a container `Dockerfile` and README for local/GitHub Actions
usage.
- Added unit and HTTP integration tests for defaults, configured stubs,
SSE, Vertex, and Bedrock EventStream behavior.
- Added proxy-level simulator-backed E2E tests that run Headroom against
the simulator across Anthropic, OpenAI Chat, OpenAI Responses, OpenAI
Conversations, Bedrock invoke/converse/streaming, Vertex raw/stream
predict, and upstream health.
- Added simulator-backed provider error-path E2E coverage for OpenAI
429, Anthropic 529, Bedrock 502, and Vertex 503 responses flowing
through Headroom unchanged.
- Added Headroom-owned preflight error E2E coverage proving Bedrock
missing credentials and invalid Vertex envelopes stop inside the proxy
instead of silently falling through to the simulator/provider.
- Fixed direct Rust `headroom-core` binaries/tests on Windows so Magika
initializes ONNX Runtime via `ort::init_from` from an explicit pip
`onnxruntime` library path, with fail-fast fallback only when no safe
runtime is discoverable.
- Added a Rust CI `simulator-e2e` matrix for `ubuntu-latest`,
`macos-latest`, and `windows-latest` that runs `cargo test -p
headroom-proxy --test e2e_simulators`.
- Gated dynamic Magika `Path`/`PathBuf` imports to Windows and x86_64
macOS so Linux clippy does not see unused dynamic-ORT-only imports.

## Testing  
- [ ] Unit tests pass (`pytest`)  
- [ ] Linting passes (`ruff check .`)  
- [ ] Type checking passes (`mypy headroom`)  
- [x] New tests added for new functionality  
- [x] Manual testing performed

### Test Output  
cargo fmt --all -- --check  
# passed  

cargo clippy --workspace -- -D warnings  
# passed  

$env:ORT_DYLIB_PATH=$null  
cargo test -p headroom-core transforms::magika_detector::tests:: --lib  
# 17 passed, 0 failed; Magika initialized from discovered pip
onnxruntime DLL

$env:ORT_DYLIB_PATH=$null  
cargo test --workspace  
# passed  

gitleaks protect --staged --no-banner --redact  
# no leaks found  

gitleaks git --log-opts="headroomlabs/main..HEAD" --no-banner --redact  
# 5 commits scanned; no leaks found

## Real Behavior Proof  
- **Environment:** Windows PowerShell, Rust toolchain `1.95.0`, clean
worktree from `headroomlabs/main` at `9bacf481`.
- **Exact simulator command / steps:**  
  - `cargo run -p headroom-simulators -- --listen 127.0.0.1:8789`  
- Point Headroom proxy upstream at `http://127.0.0.1:8789` for local
deterministic provider responses.
- Use optional `--config path/to/simulator.json` to bind exact request
fixtures.
- **Observed simulator result:**  
  - OpenAI chat default returns `chat.completion` shape.  
  - OpenAI Responses stream returns named SSE events.  
  - Vertex raw predict returns Anthropic message shape.  
- Bedrock stream can return binary `application/vnd.amazon.eventstream`
bytes.
  - Configured stubs override bottled defaults.  
- **Observed Magika result:**  
- Direct Rust `headroom-core` tests pass with `ORT_DYLIB_PATH` unset.
- Magika discovers the installed pip `onnxruntime.dll`, loads it via
`ort::init_from`, and only falls back if no safe runtime is available.
- **Not tested:**  
- No live provider calls; simulator behavior is intentionally offline
and deterministic.

## Review Readiness  
- [x] I have performed a self-review  
- [x] This PR is ready for human review

## Checklist  
- [x] My code follows the project's style guidelines  
- [x] I have performed a self-review of my code  
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation  
- [x] My changes generate no new warnings  
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes  
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)  
N/A

## Additional Notes  
No CHANGELOG entry was added because this introduces a developer/CI
simulator crate plus a Windows direct-Rust Magika runtime fix, without
changing shipped Python package behavior. The simulator intentionally
does not include a lightweight fallback LLM in this slice; unbound
inputs receive deterministic bottled responses so tests stay
reproducible and offline.
2026-07-11 09:41:49 -07:00

161 lines
4.9 KiB
Rust

use std::net::SocketAddr;
use headroom_simulators::config::{
ConfiguredResponse, JsonPointerMatch, SimulatorConfig, StubRule,
};
use headroom_simulators::{build_app, Simulator};
use serde_json::{json, Value};
use tokio::sync::oneshot;
struct TestServer {
addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
task: tokio::task::JoinHandle<()>,
}
impl TestServer {
fn url(&self) -> String {
format!("http://{}", self.addr)
}
async fn shutdown(mut self) {
if let Some(tx) = self.shutdown.take() {
let _ = tx.send(());
}
let _ = self.task.await;
}
}
async fn start(config: SimulatorConfig) -> TestServer {
let app = build_app(Simulator::new(config)).into_make_service();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let (tx, rx) = oneshot::channel::<()>();
let task = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
TestServer {
addr,
shutdown: Some(tx),
task,
}
}
#[tokio::test]
async fn openai_chat_default_is_provider_shaped() {
let server = start(SimulatorConfig::default()).await;
let response: Value = reqwest::Client::new()
.post(format!("{}/v1/chat/completions", server.url()))
.json(&json!({"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["object"], "chat.completion");
assert_eq!(response["choices"][0]["message"]["role"], "assistant");
server.shutdown().await;
}
#[tokio::test]
async fn configured_stub_overrides_default_response() {
let server = start(SimulatorConfig {
stubs: vec![StubRule {
name: "configured chat".to_string(),
method: Some("POST".to_string()),
path: "/v1/chat/completions".to_string(),
body_contains: None,
body_json_pointer: Some(JsonPointerMatch {
pointer: "/messages/0/content".to_string(),
equals: json!("configured"),
}),
response: ConfiguredResponse {
status: 209,
headers: [("x-test-stub".to_string(), "yes".to_string())].into(),
json: Some(json!({"stubbed": true})),
body: None,
sse: vec![],
},
}],
})
.await;
let response = reqwest::Client::new()
.post(format!("{}/v1/chat/completions", server.url()))
.json(&json!({"messages":[{"content":"configured"}]}))
.send()
.await
.unwrap();
assert_eq!(response.status().as_u16(), 209);
assert_eq!(response.headers()["x-test-stub"], "yes");
assert_eq!(
response.json::<Value>().await.unwrap(),
json!({"stubbed": true})
);
server.shutdown().await;
}
#[tokio::test]
async fn responses_stream_returns_named_sse_events() {
let server = start(SimulatorConfig::default()).await;
let body = reqwest::Client::new()
.post(format!("{}/v1/responses", server.url()))
.json(&json!({"model":"gpt-5","input":"hi","stream":true}))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(body.contains("event: response.created"));
assert!(body.contains("event: response.completed"));
server.shutdown().await;
}
#[tokio::test]
async fn bedrock_stream_can_emit_binary_eventstream() {
let server = start(SimulatorConfig::default()).await;
let bytes = reqwest::Client::new()
.post(format!(
"{}/model/anthropic.claude-3-haiku/invoke-with-response-stream",
server.url()
))
.header("accept", "application/vnd.amazon.eventstream")
.json(&json!({"messages":[{"role":"user","content":"hi"}]}))
.send()
.await
.unwrap()
.bytes()
.await
.unwrap();
assert!(bytes.len() > 16);
let total_len = u32::from_be_bytes(bytes[0..4].try_into().unwrap()) as usize;
assert_eq!(total_len, bytes.len());
server.shutdown().await;
}
#[tokio::test]
async fn vertex_raw_predict_default_is_anthropic_shaped() {
let server = start(SimulatorConfig::default()).await;
let response: Value = reqwest::Client::new()
.post(format!(
"{}/v1beta1/projects/p/locations/us/publishers/anthropic/models/claude:rawPredict",
server.url()
))
.json(&json!({"anthropic_version":"vertex-2023-10-16","messages":[]}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(response["type"], "message");
assert_eq!(response["role"], "assistant");
server.shutdown().await;
}