drep-ai 2.5.1

A local commit gate: runs the linters your repo configures, and sends changed code to an LLM for review
Documentation
//! `LlmClient::new` - configuration validation.
//!
//! Criteria 20 and 21: an incomplete or disabled config must surface as
//! `LlmError::NotConfigured` at construction, not at the first request. A
//! gate that builds successfully and only fails on the first analyze call
//! would silently skip analysis of every file before the user noticed.

use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use crate::config::LlmConfig;
use crate::llm::client::LlmClient;
use crate::llm::error::LlmError;
use crate::test_support::{cfg_for, sse};

/// Criterion 20: `new` returns `NotConfigured` when `enabled` is false.
#[test]
fn new_returns_not_configured_when_disabled() {
    let cfg = LlmConfig {
        enabled: false,
        endpoint: Some("http://host/v1".into()),
        model: Some("m".into()),
        ..LlmConfig::default()
    };
    let err = LlmClient::new(&cfg).unwrap_err();
    assert!(
        matches!(err, LlmError::NotConfigured(_)),
        "expected NotConfigured, got {err:?}"
    );
}

/// Criterion 21a: `new` returns `NotConfigured` when `endpoint` is None.
#[test]
fn new_returns_not_configured_when_endpoint_missing() {
    let cfg = LlmConfig {
        enabled: true,
        endpoint: None,
        model: Some("m".into()),
        ..LlmConfig::default()
    };
    let err = LlmClient::new(&cfg).unwrap_err();
    assert!(
        matches!(err, LlmError::NotConfigured(_)),
        "expected NotConfigured, got {err:?}"
    );
}

/// Criterion 21b: `new` returns `NotConfigured` when `model` is None.
#[test]
fn new_returns_not_configured_when_model_missing() {
    let cfg = LlmConfig {
        enabled: true,
        endpoint: Some("http://host/v1".into()),
        model: None,
        ..LlmConfig::default()
    };
    let err = LlmClient::new(&cfg).unwrap_err();
    assert!(
        matches!(err, LlmError::NotConfigured(_)),
        "expected NotConfigured, got {err:?}"
    );
}

/// An unset cap must reach the wire as *no* `max_tokens` at all.
///
/// open-agent-sdk 0.7.0 omits the field when the setter is never called;
/// before that it substituted 4096, and drep compensated with a large
/// sentinel. This pins that the compensation is gone and not silently
/// reintroduced.
#[tokio::test]
async fn unset_max_tokens_is_absent_from_the_request() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(sse(&["{}"]), "text/event-stream"))
        .mount(&server)
        .await;

    let cfg = cfg_for(&server, "m", 3);
    assert_eq!(cfg.max_tokens, None, "fixture must leave the cap unset");
    let client = LlmClient::new(&cfg).expect("client");
    let _ = client.complete_json("sys", "user").await;

    let reqs = server.received_requests().await.expect("log");
    let body: serde_json::Value =
        serde_json::from_slice(&reqs[0].body).expect("request body is JSON");
    // `is_none()`, not `is_none_or(is_null)`. Accepting an explicit `null`
    // lets a regression that serialises `None` as `null` pass while the field
    // is plainly on the wire - which is the thing this test exists to catch.
    assert!(
        body.get("max_tokens").is_none(),
        "an unset cap must be absent from the wire entirely, got {:?}",
        body.get("max_tokens")
    );
}

/// A configured cap must actually be forwarded.
///
/// `LlmConfig::max_tokens` was parsed and tested but never read by the client -
/// it had no such field, so a user-set ceiling was silently discarded.
#[tokio::test]
async fn a_configured_max_tokens_is_forwarded() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(sse(&["{}"]), "text/event-stream"))
        .mount(&server)
        .await;

    let mut cfg = cfg_for(&server, "m", 3);
    cfg.max_tokens = Some(1234);
    let client = LlmClient::new(&cfg).expect("client");
    let _ = client.complete_json("sys", "user").await;

    let reqs = server.received_requests().await.expect("log");
    let body: serde_json::Value =
        serde_json::from_slice(&reqs[0].body).expect("request body is JSON");
    assert_eq!(
        body["max_tokens"].as_u64(),
        Some(1234),
        "a configured cap must reach the model"
    );
}

/// An absent `protocol` builds the OpenAI-compatible client, which is what keeps
/// every config written before the field existed working unchanged.
#[test]
fn an_absent_protocol_builds_the_openai_client() {
    let cfg = LlmConfig {
        endpoint: Some("http://host/v1".into()),
        model: Some("m".into()),
        ..LlmConfig::default()
    };
    let client = LlmClient::new(&cfg).expect("builds");
    assert_eq!(client.protocol(), open_agent::ApiProtocol::OpenAiChat);
}

/// A named protocol reaches the client. Without this the field would parse,
/// validate, and then be silently dropped on the way to the request.
#[test]
fn a_named_protocol_reaches_the_client() {
    let cfg = LlmConfig {
        endpoint: Some("https://api.kimi.com/coding/v1".into()),
        model: Some("k3".into()),
        protocol: Some("anthropic".into()),
        ..LlmConfig::default()
    };
    let client = LlmClient::new(&cfg).expect("builds");
    assert_eq!(client.protocol(), open_agent::ApiProtocol::Anthropic);
}

/// `config::load` rejects an unknown name first, but `LlmClient::new` is also
/// reachable from a hand-built `LlmConfig`. Defaulting there would post
/// chat-completions bytes to a `/messages` endpoint.
#[test]
fn an_unknown_protocol_is_not_configured_rather_than_defaulted() {
    let cfg = LlmConfig {
        endpoint: Some("http://host/v1".into()),
        model: Some("m".into()),
        protocol: Some("antropic".into()),
        ..LlmConfig::default()
    };
    let err = LlmClient::new(&cfg).unwrap_err();
    assert!(
        matches!(err, LlmError::NotConfigured(ref m) if m.contains("antropic")),
        "expected NotConfigured naming the value, got {err:?}"
    );
}

/// An unset temperature survives construction as `None`, which is what makes it
/// absent from the request rather than sent as some stand-in value.
#[test]
fn an_unset_temperature_stays_unset_on_the_client() {
    let cfg = LlmConfig {
        endpoint: Some("http://host/v1".into()),
        model: Some("m".into()),
        ..LlmConfig::default()
    };
    let client = LlmClient::new(&cfg).expect("builds");
    assert_eq!(client.temperature(), None);
}