use std::fmt;
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use super::config::ExploreConfig;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const CHAT_TIMEOUT: Duration = Duration::from_secs(300);
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self::text(Role::System, content)
}
pub fn user(content: impl Into<String>) -> Self {
Self::text(Role::User, content)
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::text(Role::Assistant, content)
}
pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Message {
role: Role::Tool,
content: Some(content.into()),
tool_calls: Vec::new(),
tool_call_id: Some(tool_call_id.into()),
name: None,
}
}
fn text(role: Role, content: impl Into<String>) -> Self {
Message {
role,
content: Some(content.into()),
tool_calls: Vec::new(),
tool_call_id: None,
name: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
impl Serialize for ToolCall {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let arguments =
serde_json::to_string(&self.arguments).map_err(serde::ser::Error::custom)?;
let wire = serde_json::json!({
"id": self.id,
"type": "function",
"function": { "name": self.name, "arguments": arguments },
});
wire.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ToolCall {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
id: String,
#[serde(default)]
function: RawFunction,
}
#[derive(Deserialize, Default)]
struct RawFunction {
#[serde(default)]
name: String,
#[serde(default)]
arguments: Value,
}
let raw = Raw::deserialize(deserializer)?;
Ok(ToolCall {
id: raw.id,
name: raw.function.name,
arguments: normalize_arguments(raw.function.arguments),
})
}
}
fn normalize_arguments(v: Value) -> Value {
match v {
Value::String(s) => {
if s.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&s).unwrap_or(Value::String(s))
}
}
Value::Null => Value::Object(serde_json::Map::new()),
other => other,
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Tool {
#[serde(rename = "type")]
pub kind: String,
pub function: ToolFunction,
}
impl Tool {
pub fn function(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
) -> Self {
Tool {
kind: "function".to_string(),
function: ToolFunction {
name: name.into(),
description: description.into(),
parameters,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ToolFunction {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_template_kwargs: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
impl ChatRequest {
pub fn new(messages: Vec<Message>) -> Self {
ChatRequest {
model: String::new(),
messages,
tools: Vec::new(),
tool_choice: None,
temperature: None,
max_completion_tokens: None,
top_p: None,
top_k: None,
chat_template_kwargs: None,
reasoning_effort: None,
}
}
pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
self.tools = tools;
self
}
pub fn with_tool_choice(mut self, choice: Value) -> Self {
self.tool_choice = Some(choice);
self
}
pub fn with_bench_sampling(
mut self,
temperature: f32,
top_p: f32,
max_completion_tokens: u32,
reasoning_effort: Option<String>,
qwen: bool,
) -> Self {
self.temperature = Some(temperature);
self.top_p = Some(top_p);
self.max_completion_tokens = Some(max_completion_tokens);
self.reasoning_effort = reasoning_effort;
if qwen {
self.top_k = Some(20);
self.chat_template_kwargs = Some(serde_json::json!({ "enable_thinking": false }));
}
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatResponse {
#[serde(default)]
pub choices: Vec<Choice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
impl ChatResponse {
pub fn first_message(&self) -> Option<&Message> {
self.choices.first().map(|c| &c.message)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub total_tokens: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Choice {
pub message: Message,
#[serde(default)]
pub finish_reason: Option<String>,
}
pub trait ChatClient {
fn chat(&self, req: ChatRequest) -> Result<ChatResponse, ClientError>;
}
#[derive(Debug)]
pub enum ClientError {
Connection {
url: String,
detail: String,
},
Http {
url: String,
status: u16,
body: String,
},
Protocol {
url: String,
detail: String,
body: String,
},
Encode(String),
}
impl ClientError {
pub fn is_connection(&self) -> bool {
matches!(self, ClientError::Connection { .. })
}
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientError::Connection { url, detail } => {
write!(f, "could not reach the inference server at {url}: {detail}")
}
ClientError::Http { url, status, body } => {
write!(f, "{url} returned HTTP {status}: {}", truncate(body))
}
ClientError::Protocol { url, detail, .. } => {
write!(f, "unexpected response from {url}: {detail}")
}
ClientError::Encode(detail) => {
write!(f, "failed to encode chat request: {detail}")
}
}
}
}
impl std::error::Error for ClientError {}
#[derive(Debug)]
pub enum HealthError {
Unreachable {
url: String,
detail: String,
},
ModelMissing {
model: String,
url: String,
available: Vec<String>,
},
}
impl fmt::Display for HealthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HealthError::Unreachable { url, detail } => write!(
f,
"inference server unreachable at {url}: {detail} \
— is the server running? check `base_url` in .grove/explore.json"
),
HealthError::ModelMissing { model, url, available } => write!(
f,
"model `{model}` is not served by {url} (available: {}) \
— pull/load it, or fix `model` in .grove/explore.json",
if available.is_empty() { "none reported".to_string() } else { available.join(", ") }
),
}
}
}
impl std::error::Error for HealthError {}
fn truncate(s: &str) -> String {
const MAX: usize = 500;
if s.len() <= MAX {
s.to_string()
} else {
let mut cut = MAX;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
format!("{}… ({} bytes)", &s[..cut], s.len())
}
}
pub struct OpenAiCompatClient {
base_url: String,
model: String,
agent: ureq::Agent,
}
impl OpenAiCompatClient {
pub fn new(cfg: &ExploreConfig) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout_connect(CONNECT_TIMEOUT)
.timeout(CHAT_TIMEOUT)
.build();
OpenAiCompatClient {
base_url: cfg.base_url.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
agent,
}
}
}
impl ChatClient for OpenAiCompatClient {
fn chat(&self, req: ChatRequest) -> Result<ChatResponse, ClientError> {
let mut req = req;
req.model = self.model.clone();
let url = format!("{}/chat/completions", self.base_url);
let body = serde_json::to_string(&req).map_err(|e| ClientError::Encode(e.to_string()))?;
let resp = self
.agent
.post(&url)
.set("Content-Type", "application/json")
.send_string(&body);
let resp = match resp {
Ok(r) => r,
Err(ureq::Error::Status(status, r)) => {
let body = r.into_string().unwrap_or_default();
return Err(ClientError::Http { url, status, body });
}
Err(ureq::Error::Transport(t)) => {
return Err(ClientError::Connection { url, detail: t.to_string() });
}
};
let raw = resp
.into_string()
.map_err(|e| ClientError::Connection { url: url.clone(), detail: e.to_string() })?;
serde_json::from_str(&raw).map_err(|e| ClientError::Protocol {
url,
detail: e.to_string(),
body: raw,
})
}
}
#[derive(Deserialize)]
struct ModelsResponse {
#[serde(default)]
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
#[serde(default)]
id: String,
}
pub fn health_probe(cfg: &ExploreConfig) -> Result<(), HealthError> {
let base = cfg.base_url.trim_end_matches('/');
let url = format!("{base}/models");
let agent = ureq::AgentBuilder::new()
.timeout_connect(CONNECT_TIMEOUT)
.timeout(PROBE_TIMEOUT)
.build();
let resp = agent.get(&url).call().map_err(|e| match e {
ureq::Error::Status(status, r) => HealthError::Unreachable {
url: url.clone(),
detail: format!("HTTP {status}: {}", truncate(&r.into_string().unwrap_or_default())),
},
ureq::Error::Transport(t) => {
HealthError::Unreachable { url: url.clone(), detail: t.to_string() }
}
})?;
let raw = resp
.into_string()
.map_err(|e| HealthError::Unreachable { url: url.clone(), detail: e.to_string() })?;
let listing: ModelsResponse = serde_json::from_str(&raw).map_err(|e| {
HealthError::Unreachable {
url: url.clone(),
detail: format!("unparseable /models response: {e}"),
}
})?;
let available: Vec<String> = listing.data.into_iter().map(|m| m.id).collect();
if model_available(&cfg.model, &available) {
Ok(())
} else {
Err(HealthError::ModelMissing { model: cfg.model.clone(), url, available })
}
}
pub fn list_models(cfg: &ExploreConfig) -> Result<Vec<String>, String> {
let base = cfg.base_url.trim_end_matches('/');
let url = format!("{base}/models");
let agent = ureq::AgentBuilder::new()
.timeout_connect(CONNECT_TIMEOUT)
.timeout(PROBE_TIMEOUT)
.build();
let resp = agent.get(&url).call().map_err(|e| e.to_string())?;
let raw = resp.into_string().map_err(|e| e.to_string())?;
let listing: ModelsResponse =
serde_json::from_str(&raw).map_err(|e| format!("unparseable /models response: {e}"))?;
Ok(listing
.data
.into_iter()
.map(|m| m.id)
.filter(|id| !id.is_empty())
.collect())
}
fn model_available(want: &str, have: &[String]) -> bool {
if have.is_empty() {
return true;
}
let want_base = want.split(':').next().unwrap_or(want);
have.iter().any(|id| {
if id == want || id.contains(want) {
return true;
}
let stem = Path::new(id)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(id);
stem == want || (!want_base.is_empty() && stem.contains(want_base))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn unreachable_config() -> ExploreConfig {
ExploreConfig {
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "test-model".to_string(),
..ExploreConfig::default()
}
}
#[test]
fn chat_request_serializes_lean() {
let req = ChatRequest::new(vec![
Message::system("be helpful"),
Message::user("hello"),
]);
let v: Value = serde_json::to_value(&req).unwrap();
assert_eq!(v["messages"][0]["role"], "system");
assert_eq!(v["messages"][1]["role"], "user");
assert_eq!(v["messages"][1]["content"], "hello");
assert!(v.get("tools").is_none(), "empty tools omitted");
assert!(v.get("tool_choice").is_none(), "unset tool_choice omitted");
assert!(v.get("temperature").is_none(), "unset temperature omitted");
assert!(v["messages"][1].get("tool_call_id").is_none());
assert!(v["messages"][1].get("tool_calls").is_none());
}
#[test]
fn request_with_tools_and_choice_serializes() {
let tool = Tool::function(
"search",
"search the code",
serde_json::json!({"type": "object", "properties": {"q": {"type": "string"}}}),
);
let req = ChatRequest::new(vec![Message::user("find foo")])
.with_tools(vec![tool])
.with_tool_choice(serde_json::json!("auto"));
let v: Value = serde_json::to_value(&req).unwrap();
assert_eq!(v["tools"][0]["type"], "function");
assert_eq!(v["tools"][0]["function"]["name"], "search");
assert_eq!(v["tools"][0]["function"]["parameters"]["type"], "object");
assert_eq!(v["tool_choice"], "auto");
}
#[test]
fn tool_message_carries_call_id() {
let m = Message::tool("call_42", "{\"result\": 1}");
let v: Value = serde_json::to_value(&m).unwrap();
assert_eq!(v["role"], "tool");
assert_eq!(v["tool_call_id"], "call_42");
assert_eq!(v["content"], "{\"result\": 1}");
}
const LLAMACPP_RESPONSE: &str = r#"{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "search", "arguments": {"q": "foo", "n": 3} }
}]
}
}]
}"#;
const OLLAMA_RESPONSE: &str = r#"{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "search", "arguments": "{\"q\": \"foo\", \"n\": 3}" }
}]
}
}]
}"#;
#[test]
fn provider_dialects_normalize_to_identical_tool_calls() {
let a: ChatResponse = serde_json::from_str(LLAMACPP_RESPONSE).unwrap();
let b: ChatResponse = serde_json::from_str(OLLAMA_RESPONSE).unwrap();
let ta = &a.first_message().unwrap().tool_calls[0];
let tb = &b.first_message().unwrap().tool_calls[0];
assert_eq!(ta, tb, "object-args and string-args normalize identically");
assert_eq!(ta.id, "call_1");
assert_eq!(ta.name, "search");
assert_eq!(ta.arguments["q"], "foo");
assert_eq!(ta.arguments["n"], 3);
}
#[test]
fn empty_and_null_arguments_normalize_to_object() {
assert_eq!(normalize_arguments(Value::Null), serde_json::json!({}));
assert_eq!(normalize_arguments(Value::String(String::new())), serde_json::json!({}));
assert_eq!(normalize_arguments(Value::String(" ".into())), serde_json::json!({}));
}
#[test]
fn unparseable_string_arguments_degrade_gracefully() {
let got = normalize_arguments(Value::String("not json".into()));
assert_eq!(got, Value::String("not json".into()));
}
#[test]
fn tool_call_round_trips_through_canonical_shape() {
let tc = ToolCall {
id: "call_9".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "bar"}),
};
let v: Value = serde_json::to_value(&tc).unwrap();
assert_eq!(v["type"], "function");
assert_eq!(v["function"]["name"], "search");
assert!(v["function"]["arguments"].is_string());
let back: ToolCall = serde_json::from_value(v).unwrap();
assert_eq!(back, tc);
}
#[test]
fn usage_is_parsed_when_present_and_absent() {
let with = r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}],
"usage":{"prompt_tokens":120,"completion_tokens":8,"total_tokens":128}}"#;
let resp: ChatResponse = serde_json::from_str(with).unwrap();
let u = resp.usage.expect("usage present");
assert_eq!(u.prompt_tokens, 120);
assert_eq!(u.completion_tokens, 8);
assert_eq!(u.total_tokens, 128);
let without = r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}]}"#;
let resp: ChatResponse = serde_json::from_str(without).unwrap();
assert!(resp.usage.is_none());
let partial = r#"{"choices":[],"usage":{"prompt_tokens":5}}"#;
let resp: ChatResponse = serde_json::from_str(partial).unwrap();
let u = resp.usage.unwrap();
assert_eq!(u.prompt_tokens, 5);
assert_eq!(u.completion_tokens, 0);
}
#[test]
fn response_without_tool_calls_is_plain_text() {
let json = r#"{"choices":[{"message":{"role":"assistant","content":"hi there"}}]}"#;
let resp: ChatResponse = serde_json::from_str(json).unwrap();
let m = resp.first_message().unwrap();
assert_eq!(m.content.as_deref(), Some("hi there"));
assert!(m.tool_calls.is_empty());
}
#[test]
fn chat_against_unreachable_url_is_connection_error() {
let client = OpenAiCompatClient::new(&unreachable_config());
let err = client
.chat(ChatRequest::new(vec![Message::user("hi")]))
.expect_err("a closed port must not yield a response");
assert!(err.is_connection(), "expected Connection, got {err:?}");
match err {
ClientError::Connection { url, .. } => {
assert!(url.contains("127.0.0.1:1"), "message names the endpoint: {url}");
assert!(url.ends_with("/chat/completions"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn health_probe_against_unreachable_url_is_unreachable() {
let err = health_probe(&unreachable_config())
.expect_err("a closed port must not pass the health probe");
match err {
HealthError::Unreachable { url, .. } => {
assert!(url.contains("127.0.0.1:1"), "message names the endpoint: {url}");
assert!(url.ends_with("/models"));
}
other => panic!("expected Unreachable, got {other:?}"),
}
}
#[test]
fn model_matching_is_tolerant() {
assert!(model_available("qwen2.5-coder:7b", &["qwen2.5-coder:7b".into()]));
assert!(model_available(
"qwen2.5-coder:7b",
&["/models/qwen2.5-coder-7b-instruct.gguf".into()]
));
assert!(model_available("anything", &[]));
assert!(!model_available("llama3", &["qwen2.5-coder:7b".into()]));
}
#[test]
fn truncate_does_not_panic_on_multibyte_boundary() {
let s = format!("{}{}", "a".repeat(499), "é".repeat(50));
let out = truncate(&s); assert!(out.ends_with(&format!("({} bytes)", s.len())));
let body = out.split('…').next().unwrap();
assert!(body.len() <= 500);
assert!(s.starts_with(body));
assert_eq!(truncate("héllo"), "héllo");
}
}