xai-grok 0.1.0

High-level async client for the xAI Grok API (chat, streaming, tool calling, vision, structured outputs), built on the stream-rs streaming toolkit.
Documentation
//! HTTP-layer tests against an in-process mock server.
//!
//! Unlike `tests/streaming.rs` (which drives the parsing pipeline directly),
//! these spin up a real `wiremock` HTTP server and point a `GrokClient` at it
//! via the builder's `base_url`. They exercise the *actual* reqwest transport:
//! request method/path, the `Authorization: Bearer` header, JSON
//! (de)serialization, non-2xx status mapping, and end-to-end SSE streaming over
//! a chunked HTTP body — all without touching the network.

use futures_util::StreamExt;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
use xai_grok::{ChatRequest, GrokClient, Message};

/// Build a client pointed at the mock server with a known API key.
fn client(server: &MockServer) -> GrokClient {
    GrokClient::builder()
        .api_key("xai-test-key")
        .base_url(server.uri())
        .build()
        .expect("client builds with key + base_url")
}

fn req() -> ChatRequest {
    ChatRequest::new("grok-4", vec![Message::user("hi")])
}

#[tokio::test]
async fn chat_sends_bearer_auth_to_correct_path() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .and(header("authorization", "Bearer xai-test-key"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "id": "cmpl-1",
            "model": "grok-4",
            "choices": [{
                "index": 0,
                "message": { "role": "assistant", "content": "hello there" },
                "finish_reason": "stop"
            }]
        })))
        .expect(1)
        .mount(&server)
        .await;

    let resp = client(&server)
        .chat(req())
        .await
        .expect("2xx chat response");
    assert_eq!(resp.content(), Some("hello there"));
    // `.expect(1)` above is verified on drop: exactly one matching request.
}

#[tokio::test]
async fn chat_forces_stream_false_in_body() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(|request: &Request| {
            let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
            // The client must force `stream: false` for the buffered `chat()` path.
            assert_eq!(body["stream"], serde_json::json!(false));
            ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "id": "cmpl-2",
                "model": "grok-4",
                "choices": [{
                    "index": 0,
                    "message": { "role": "assistant", "content": "ok" }
                }]
            }))
        })
        .mount(&server)
        .await;

    let resp = client(&server).chat(req()).await.expect("ok");
    assert_eq!(resp.content(), Some("ok"));
}

#[tokio::test]
async fn non_2xx_maps_to_api_error_with_status_and_body() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(429).set_body_string(r#"{"error":"rate limited"}"#))
        .mount(&server)
        .await;

    let err = client(&server)
        .chat(req())
        .await
        .expect_err("429 should surface as an error");

    match err {
        xai_grok::Error::Api { status, body } => {
            assert_eq!(status, 429);
            assert!(body.contains("rate limited"), "body preserved: {body}");
        }
        other => panic!("expected Error::Api, got {other:?}"),
    }
}

#[tokio::test]
async fn chat_stream_parses_sse_over_http() {
    let server = MockServer::start().await;

    // A realistic chunked SSE body: two content deltas, a finish, then [DONE].
    let sse = concat!(
        "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"}}]}\n\n",
        "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"}}]}\n\n",
        "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
        "data: [DONE]\n\n",
    );

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .and(header("authorization", "Bearer xai-test-key"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "text/event-stream")
                .set_body_string(sse),
        )
        .mount(&server)
        .await;

    let acc = client(&server)
        .chat_stream_collect(req(), |_tok| {})
        .await
        .expect("stream collects cleanly");

    let choice = acc.choice(0).expect("a choice streamed");
    assert_eq!(choice.content, "Hello");
    assert_eq!(choice.role.as_deref(), Some("assistant"));
    assert_eq!(choice.finish_reason.as_deref(), Some("stop"));
}

#[tokio::test]
async fn chat_stream_up_front_status_error() {
    let server = MockServer::start().await;

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
        .mount(&server)
        .await;

    // `ChatStream` does not implement `Debug`, so match on the result directly
    // rather than using `expect_err`.
    match client(&server).chat_stream(req()).await {
        Err(xai_grok::Error::Api { status, .. }) => assert_eq!(status, 401),
        Err(other) => panic!("expected Error::Api, got {other:?}"),
        Ok(_) => panic!("a streaming request to a 401 must fail up front"),
    }
}

#[tokio::test]
async fn done_sentinel_terminates_stream() {
    let server = MockServer::start().await;
    let sse = concat!(
        "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n",
        "data: [DONE]\n\n",
    );

    Mock::given(method("POST"))
        .and(path("/chat/completions"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("content-type", "text/event-stream")
                .set_body_string(sse),
        )
        .mount(&server)
        .await;

    let mut stream = client(&server).chat_stream(req()).await.expect("ok");
    let mut chunks = 0;
    while let Some(item) = stream.next().await {
        item.expect("chunk decodes");
        chunks += 1;
    }
    // Exactly one content chunk; [DONE] ends the stream without an extra item.
    assert_eq!(chunks, 1);
}