use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use super::{AiClient, AiClientCapabilities, AiClientMetadata, RequestOptions};
use crate::claude::{error::ClaudeError, model_config::get_model_registry};
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProbeSource {
LmStudio,
Ollama,
}
impl ProbeSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::LmStudio => "lmstudio",
Self::Ollama => "ollama",
}
}
}
#[derive(Serialize, Debug)]
struct Message {
role: String,
content: String,
}
#[derive(Serialize, Debug)]
struct ResponseFormatField {
#[serde(rename = "type")]
kind: &'static str,
json_schema: JsonSchemaSpec,
}
#[derive(Serialize, Debug)]
struct JsonSchemaSpec {
name: &'static str,
strict: bool,
schema: serde_json::Value,
}
#[derive(Serialize, Debug)]
struct OpenAiRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_completion_tokens: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<ResponseFormatField>,
}
#[derive(Deserialize, Debug)]
struct Choice {
message: ResponseMessage,
#[allow(dead_code)] finish_reason: Option<String>,
}
#[derive(Deserialize, Debug)]
struct ResponseMessage {
#[allow(dead_code)] role: String,
content: String,
}
#[derive(Deserialize, Debug)]
struct OpenAiResponse {
choices: Vec<Choice>,
model: Option<String>,
usage: Option<Usage>,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)] struct Usage {
prompt_tokens: Option<i32>,
completion_tokens: Option<i32>,
total_tokens: Option<i32>,
}
pub struct OpenAiAiClient {
client: Client,
api_key: Option<String>,
model: String,
base_url: String,
max_tokens: Option<i32>,
temperature: Option<f32>,
active_beta: Option<(String, String)>,
loaded_context_length: Option<usize>,
}
#[derive(Deserialize, Debug)]
struct LmStudioModelsResponse {
data: Vec<LmStudioModel>,
}
#[derive(Deserialize, Debug)]
struct LmStudioModel {
id: String,
state: Option<String>,
loaded_context_length: Option<usize>,
}
impl OpenAiAiClient {
pub fn new(
model: String,
api_key: Option<String>,
base_url: String,
max_tokens: Option<i32>,
temperature: Option<f32>,
active_beta: Option<(String, String)>,
) -> Result<Self> {
let client = super::build_http_client()?;
Ok(Self {
client,
api_key,
model,
base_url,
max_tokens,
temperature,
active_beta,
loaded_context_length: None,
})
}
pub fn new_ollama(
model: String,
base_url: Option<String>,
active_beta: Option<(String, String)>,
) -> Result<Self> {
Self::new(
model,
None, base_url.unwrap_or_else(|| "http://localhost:11434".to_string()),
Some(4096), Some(0.1), active_beta,
)
}
pub fn new_openai(
model: String,
api_key: String,
active_beta: Option<(String, String)>,
) -> Result<Self> {
Self::new(
model,
Some(api_key),
"https://api.openai.com".to_string(),
None, Some(0.1), active_beta,
)
}
fn get_max_tokens(&self) -> i32 {
if let Some(configured_max) = self.max_tokens {
return configured_max;
}
super::registry_max_output_tokens(&self.model, &self.active_beta)
}
fn get_api_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions");
debug!(base_url = %self.base_url, full_url = %url, "Constructed OpenAI-compatible API URL");
url
}
fn is_ollama(&self) -> bool {
self.base_url.contains("localhost")
|| self.base_url.contains("127.0.0.1")
|| self.api_key.is_none()
}
fn is_gpt5_series(&self) -> bool {
self.model.starts_with("gpt-5") || self.model.starts_with("o1")
}
#[must_use]
pub fn loaded_context_length(&self) -> Option<usize> {
self.loaded_context_length
}
pub fn set_loaded_context_length(&mut self, value: usize) {
self.loaded_context_length = Some(value);
}
fn build_request(
&self,
system_prompt: &str,
user_prompt: &str,
response_format: Option<ResponseFormatField>,
) -> OpenAiRequest {
let mut messages = Vec::new();
if !system_prompt.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system_prompt.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user_prompt.to_string(),
});
let max_tokens = self.get_max_tokens();
if self.is_gpt5_series() {
OpenAiRequest {
model: self.model.clone(),
messages,
max_tokens: None,
max_completion_tokens: Some(max_tokens),
temperature: None,
stream: false,
response_format,
}
} else {
OpenAiRequest {
model: self.model.clone(),
messages,
max_tokens: Some(max_tokens),
max_completion_tokens: None,
temperature: self.temperature,
stream: false,
response_format,
}
}
}
async fn send_inner(&self, request: OpenAiRequest) -> Result<String> {
debug!(
max_tokens = ?request.max_tokens,
max_completion_tokens = ?request.max_completion_tokens,
configured_temperature = ?self.temperature,
effective_temperature = ?request.temperature,
message_count = request.messages.len(),
is_gpt5_series = self.is_gpt5_series(),
response_format_set = request.response_format.is_some(),
"Built OpenAI-compatible request payload"
);
let api_url = self.get_api_url();
info!(url = %api_url, model = %self.model, "Sending request to OpenAI-compatible API");
let mut req_builder = self
.client
.post(&api_url)
.header("Content-Type", "application/json")
.json(&request);
if let Some(ref api_key) = self.api_key {
req_builder = req_builder.header("Authorization", format!("Bearer {api_key}"));
}
let response = req_builder
.send()
.await
.map_err(|e| ClaudeError::NetworkError(e.to_string()))?;
let response = super::check_error_response(response).await?;
let openai_response: OpenAiResponse = response
.json()
.await
.map_err(|e| ClaudeError::InvalidResponseFormat(e.to_string()))?;
debug!(
choice_count = openai_response.choices.len(),
model = ?openai_response.model,
usage = ?openai_response.usage,
"Received OpenAI-compatible API response"
);
let result = openai_response
.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| {
ClaudeError::InvalidResponseFormat("No choices in response".to_string()).into()
});
super::log_response_success("OpenAI-compatible", &result);
result
}
pub async fn probe_loaded_context_length(&mut self) -> Option<ProbeSource> {
let host = host_root(&self.base_url);
if let Some(value) = probe_lm_studio(&self.client, &host, &self.model).await {
self.loaded_context_length = Some(value);
return Some(ProbeSource::LmStudio);
}
if let Some(value) = probe_ollama_native(&self.client, &host, &self.model).await {
self.loaded_context_length = Some(value);
return Some(ProbeSource::Ollama);
}
None
}
}
fn host_root(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
trimmed
.strip_suffix("/v1")
.unwrap_or(trimmed)
.trim_end_matches('/')
.to_string()
}
async fn probe_lm_studio(client: &Client, host: &str, model: &str) -> Option<usize> {
let url = format!("{host}/api/v0/models");
debug!(url = %url, model = %model, "Probing LM Studio for loaded context length");
let response = client.get(&url).timeout(PROBE_TIMEOUT).send().await.ok()?;
if !response.status().is_success() {
debug!(status = %response.status(), "LM Studio probe returned non-success");
return None;
}
let body: LmStudioModelsResponse = response.json().await.ok()?;
body.data
.into_iter()
.find(|entry| entry.id == model && entry.state.as_deref() == Some("loaded"))
.and_then(|entry| entry.loaded_context_length)
}
async fn probe_ollama_native(client: &Client, host: &str, model: &str) -> Option<usize> {
let url = format!("{host}/api/show");
debug!(url = %url, model = %model, "Probing Ollama for loaded context length");
let response = client
.post(&url)
.timeout(PROBE_TIMEOUT)
.json(&serde_json::json!({ "name": model }))
.send()
.await
.ok()?;
if !response.status().is_success() {
debug!(status = %response.status(), "Ollama probe returned non-success");
return None;
}
let body: serde_json::Value = response.json().await.ok()?;
let model_info = body.get("model_info")?.as_object()?;
for (key, value) in model_info {
if key.ends_with(".context_length") {
if let Some(n) = value.as_u64() {
return usize::try_from(n).ok();
}
}
}
None
}
impl AiClient for OpenAiAiClient {
fn send_request<'a>(
&'a self,
system_prompt: &'a str,
user_prompt: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
debug!(
system_prompt_len = system_prompt.len(),
user_prompt_len = user_prompt.len(),
model = %self.model,
base_url = %self.base_url,
is_ollama = self.is_ollama(),
"Preparing OpenAI-compatible API request"
);
let request = self.build_request(system_prompt, user_prompt, None);
self.send_inner(request).await
})
}
fn capabilities(&self) -> AiClientCapabilities {
AiClientCapabilities {
supports_response_schema: true,
}
}
fn send_request_with_options<'a>(
&'a self,
system_prompt: &'a str,
user_prompt: &'a str,
options: RequestOptions,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
debug!(
system_prompt_len = system_prompt.len(),
user_prompt_len = user_prompt.len(),
has_schema = options.response_schema.is_some(),
model = %self.model,
base_url = %self.base_url,
is_ollama = self.is_ollama(),
"Preparing OpenAI-compatible API request (with options)"
);
let response_format = options.response_schema.map(|schema| ResponseFormatField {
kind: "json_schema",
json_schema: JsonSchemaSpec {
name: "response",
strict: true,
schema,
},
});
let request = self.build_request(system_prompt, user_prompt, response_format);
self.send_inner(request).await
})
}
fn get_metadata(&self) -> AiClientMetadata {
let registry = get_model_registry();
let max_context_length = if let Some(probed) = self.loaded_context_length {
probed
} else if registry.get_input_context(&self.model) > 0 {
registry.get_input_context(&self.model)
} else {
32768 };
let max_response_length = if registry.get_max_output_tokens(&self.model) > 0 {
registry.get_max_output_tokens(&self.model)
} else {
4096 };
let provider = if self.is_ollama() {
"Ollama".to_string()
} else {
"OpenAI".to_string()
};
AiClientMetadata {
provider,
model: self.model.clone(),
max_context_length,
max_response_length,
active_beta: self.active_beta.clone(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn new_ollama() {
let client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
assert_eq!(client.model, "llama2");
assert_eq!(client.base_url, "http://localhost:11434");
assert!(client.api_key.is_none());
assert!(client.is_ollama());
}
#[test]
fn new_ollama_custom_url() {
let client = OpenAiAiClient::new_ollama(
"codellama".to_string(),
Some("http://192.168.1.100:11434".to_string()),
None,
)
.unwrap();
assert_eq!(client.base_url, "http://192.168.1.100:11434");
assert!(client.is_ollama());
}
#[test]
fn new_openai() {
let client =
OpenAiAiClient::new_openai("gpt-4".to_string(), "sk-test123".to_string(), None)
.unwrap();
assert_eq!(client.model, "gpt-4");
assert_eq!(client.base_url, "https://api.openai.com");
assert_eq!(client.api_key, Some("sk-test123".to_string()));
assert!(!client.is_ollama());
}
#[test]
fn get_api_url() {
let client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
let url = client.get_api_url();
assert_eq!(url, "http://localhost:11434/v1/chat/completions");
}
#[test]
fn get_api_url_trailing_slash() {
let client = OpenAiAiClient::new(
"test-model".to_string(),
None,
"http://localhost:11434/".to_string(),
None,
None,
None,
)
.unwrap();
let url = client.get_api_url();
assert_eq!(url, "http://localhost:11434/v1/chat/completions");
}
#[test]
fn is_ollama_detection() {
let ollama_client = OpenAiAiClient::new(
"llama2".to_string(),
None,
"http://localhost:11434".to_string(),
None,
None,
None,
)
.unwrap();
assert!(ollama_client.is_ollama());
let local_client = OpenAiAiClient::new(
"llama2".to_string(),
Some("fake-key".to_string()),
"http://127.0.0.1:11434".to_string(),
None,
None,
None,
)
.unwrap();
assert!(local_client.is_ollama());
let no_key_client = OpenAiAiClient::new(
"llama2".to_string(),
None,
"http://remote-server.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(no_key_client.is_ollama());
let openai_client = OpenAiAiClient::new(
"gpt-4".to_string(),
Some("sk-real-key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(!openai_client.is_ollama());
}
#[test]
fn gpt5_series_gpt5_models() {
let client = OpenAiAiClient::new(
"gpt-5-preview".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(client.is_gpt5_series());
let client2 = OpenAiAiClient::new(
"gpt-5".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(client2.is_gpt5_series());
}
#[test]
fn gpt5_series_o1_models() {
let client = OpenAiAiClient::new(
"o1-mini".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(client.is_gpt5_series());
let client2 = OpenAiAiClient::new(
"o1-preview".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(client2.is_gpt5_series());
}
#[test]
fn gpt5_series_regular_models_not_matched() {
let client = OpenAiAiClient::new(
"gpt-4".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(!client.is_gpt5_series());
let client2 = OpenAiAiClient::new(
"gpt-4o-mini".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
None,
None,
None,
)
.unwrap();
assert!(!client2.is_gpt5_series());
}
#[test]
fn get_max_tokens_configured_value_wins() {
let client = OpenAiAiClient::new(
"gpt-4".to_string(),
Some("key".to_string()),
"https://api.openai.com".to_string(),
Some(8192),
None,
None,
)
.unwrap();
assert_eq!(client.get_max_tokens(), 8192);
}
#[test]
fn get_max_tokens_from_registry() {
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), None).unwrap();
let tokens = client.get_max_tokens();
assert!(tokens > 0, "expected positive token limit, got {tokens}");
}
#[test]
fn get_metadata_openai() {
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), None).unwrap();
let metadata = client.get_metadata();
assert_eq!(metadata.provider, "OpenAI");
assert_eq!(metadata.model, "gpt-4o");
assert!(metadata.active_beta.is_none());
}
#[test]
fn get_metadata_ollama() {
let client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
let metadata = client.get_metadata();
assert_eq!(metadata.provider, "Ollama");
assert_eq!(metadata.model, "llama2");
}
#[test]
fn get_metadata_with_beta() {
let beta = Some(("anthropic-beta".to_string(), "output-128k".to_string()));
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), beta).unwrap();
let metadata = client.get_metadata();
assert!(metadata.active_beta.is_some());
let (key, value) = metadata.active_beta.unwrap();
assert_eq!(key, "anthropic-beta");
assert_eq!(value, "output-128k");
}
#[test]
fn request_gpt5_uses_max_completion_tokens() {
let request = OpenAiRequest {
model: "gpt-5".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: "hello".to_string(),
}],
max_tokens: None,
max_completion_tokens: Some(4096),
temperature: None,
stream: false,
response_format: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("max_completion_tokens"));
assert!(!json.contains("\"max_tokens\""));
}
#[test]
fn request_regular_model_uses_max_tokens() {
let request = OpenAiRequest {
model: "gpt-4".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: "hello".to_string(),
}],
max_tokens: Some(4096),
max_completion_tokens: None,
temperature: Some(0.1),
stream: false,
response_format: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"max_tokens\""));
assert!(!json.contains("max_completion_tokens"));
assert!(json.contains("\"temperature\""));
}
#[test]
fn capabilities_advertise_response_schema_support_openai() {
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), None).unwrap();
assert!(client.capabilities().supports_response_schema);
}
#[test]
fn capabilities_advertise_response_schema_support_ollama() {
let client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
assert!(client.capabilities().supports_response_schema);
}
#[test]
fn build_request_omits_response_format_without_schema() {
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), None).unwrap();
let request = client.build_request("sys", "user", None);
let body = serde_json::to_value(&request).unwrap();
assert!(
body.get("response_format").is_none(),
"expected response_format to be omitted, got: {body}"
);
}
#[test]
fn build_request_embeds_response_format_with_schema_regular_model() {
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), None).unwrap();
let schema = serde_json::json!({
"type": "object",
"properties": { "answer": { "type": "string" } },
"required": ["answer"],
"additionalProperties": false,
});
let response_format = Some(ResponseFormatField {
kind: "json_schema",
json_schema: JsonSchemaSpec {
name: "response",
strict: true,
schema: schema.clone(),
},
});
let request = client.build_request("sys", "user", response_format);
let body = serde_json::to_value(&request).unwrap();
assert_eq!(body["response_format"]["type"], "json_schema");
assert_eq!(body["response_format"]["json_schema"]["name"], "response");
assert_eq!(body["response_format"]["json_schema"]["strict"], true);
assert_eq!(body["response_format"]["json_schema"]["schema"], schema);
assert!(body.get("max_tokens").is_some());
assert!(body.get("max_completion_tokens").is_none());
}
#[test]
fn build_request_embeds_response_format_with_schema_gpt5() {
let client =
OpenAiAiClient::new_openai("gpt-5".to_string(), "key".to_string(), None).unwrap();
let schema = serde_json::json!({ "type": "object", "additionalProperties": false });
let response_format = Some(ResponseFormatField {
kind: "json_schema",
json_schema: JsonSchemaSpec {
name: "response",
strict: true,
schema: schema.clone(),
},
});
let request = client.build_request("sys", "user", response_format);
let body = serde_json::to_value(&request).unwrap();
assert_eq!(body["response_format"]["type"], "json_schema");
assert_eq!(body["response_format"]["json_schema"]["schema"], schema);
assert!(body.get("max_completion_tokens").is_some());
assert!(body.get("max_tokens").is_none());
assert!(body.get("temperature").is_none());
}
#[test]
fn host_root_strips_trailing_slash() {
assert_eq!(host_root("http://localhost:1234/"), "http://localhost:1234");
}
#[test]
fn host_root_strips_v1_suffix() {
assert_eq!(
host_root("http://localhost:1234/v1"),
"http://localhost:1234"
);
}
#[test]
fn host_root_strips_v1_with_trailing_slash() {
assert_eq!(
host_root("http://localhost:1234/v1/"),
"http://localhost:1234"
);
}
#[test]
fn host_root_passthrough_when_no_v1() {
assert_eq!(
host_root("http://localhost:11434"),
"http://localhost:11434"
);
}
#[test]
fn probe_source_as_str_stable() {
assert_eq!(ProbeSource::LmStudio.as_str(), "lmstudio");
assert_eq!(ProbeSource::Ollama.as_str(), "ollama");
}
#[test]
fn metadata_uses_probed_value_when_set() {
let mut client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
client.set_loaded_context_length(8192);
let metadata = client.get_metadata();
assert_eq!(metadata.max_context_length, 8192);
}
#[test]
fn metadata_falls_back_to_registry_when_probe_value_absent() {
let client =
OpenAiAiClient::new_ollama("totally-unknown-model".to_string(), None, None).unwrap();
let metadata = client.get_metadata();
let expected = get_model_registry().get_input_context("totally-unknown-model");
assert_eq!(metadata.max_context_length, expected);
}
#[test]
fn loaded_context_length_starts_unset() {
let client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
assert!(client.loaded_context_length().is_none());
}
#[test]
fn loaded_context_length_round_trips() {
let mut client = OpenAiAiClient::new_ollama("llama2".to_string(), None, None).unwrap();
client.set_loaded_context_length(4096);
assert_eq!(client.loaded_context_length(), Some(4096));
}
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ollama_client_pointing_at(server_uri: &str, model: &str) -> OpenAiAiClient {
OpenAiAiClient::new_ollama(model.to_string(), Some(server_uri.to_string()), None).unwrap()
}
#[tokio::test]
async fn probe_returns_lm_studio_value_when_model_loaded() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{
"id": "llama-3.2-3b-instruct",
"state": "loaded",
"loaded_context_length": 4096_u64,
"max_context_length": 131_072_u64,
}
]
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "llama-3.2-3b-instruct");
let source = client.probe_loaded_context_length().await;
assert_eq!(source, Some(ProbeSource::LmStudio));
assert_eq!(client.loaded_context_length(), Some(4096));
assert_eq!(client.get_metadata().max_context_length, 4096);
}
#[tokio::test]
async fn probe_skips_lm_studio_entry_when_model_not_loaded() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{ "id": "model-a", "state": "not-loaded", "loaded_context_length": 4096_u64 }
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.and(body_json(serde_json::json!({ "name": "model-a" })))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model_info": { "llama.context_length": 8192_u64 }
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "model-a");
let source = client.probe_loaded_context_length().await;
assert_eq!(source, Some(ProbeSource::Ollama));
assert_eq!(client.loaded_context_length(), Some(8192));
}
#[tokio::test]
async fn probe_skips_lm_studio_when_model_id_does_not_match() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{ "id": "other-model", "state": "loaded", "loaded_context_length": 4096_u64 }
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "wanted-model");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
assert!(client.loaded_context_length().is_none());
}
#[tokio::test]
async fn probe_falls_back_to_ollama_native() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.and(body_json(serde_json::json!({ "name": "qwen2.5-coder" })))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 32768_u64,
"qwen2.embedding_length": 3584_u64
}
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "qwen2.5-coder");
let source = client.probe_loaded_context_length().await;
assert_eq!(source, Some(ProbeSource::Ollama));
assert_eq!(client.loaded_context_length(), Some(32768));
}
#[tokio::test]
async fn probe_returns_none_when_neither_endpoint_responds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
assert!(client.loaded_context_length().is_none());
let registry_value = get_model_registry().get_input_context("anything");
assert_eq!(client.get_metadata().max_context_length, registry_value);
}
#[tokio::test]
async fn probe_returns_none_when_ollama_payload_lacks_context_length_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model_info": {
"general.architecture": "phantom",
"phantom.embedding_length": 1024_u64
}
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "ghost");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_handles_v1_suffix_in_base_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{ "id": "lm", "state": "loaded", "loaded_context_length": 2048_u64 }
]
})))
.mount(&server)
.await;
let base_with_v1 = format!("{}/v1", server.uri());
let mut client = ollama_client_pointing_at(&base_with_v1, "lm");
let source = client.probe_loaded_context_length().await;
assert_eq!(source, Some(ProbeSource::LmStudio));
assert_eq!(client.loaded_context_length(), Some(2048));
}
#[tokio::test]
async fn probe_ignores_lm_studio_entry_with_no_loaded_context_length() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [ { "id": "x", "state": "loaded", "loaded_context_length": null } ]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "x");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_returns_none_when_lm_studio_returns_invalid_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html>not json</html>"))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_returns_none_when_ollama_returns_invalid_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(200).set_body_string("{not json"))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_returns_none_when_ollama_response_lacks_model_info() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"details": { "family": "llama" }
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_returns_none_when_ollama_model_info_is_not_object() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model_info": "not an object"
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_returns_none_when_ollama_context_length_is_not_u64() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v0/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/show"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model_info": { "llama.context_length": "8192" }
})))
.mount(&server)
.await;
let mut client = ollama_client_pointing_at(&server.uri(), "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
#[tokio::test]
async fn probe_returns_none_when_server_unreachable() {
let mut client = ollama_client_pointing_at("http://127.0.0.1:1", "anything");
let source = client.probe_loaded_context_length().await;
assert!(source.is_none());
}
async fn mock_chat_completion_ok(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"choices": [
{
"message": { "role": "assistant", "content": "ok" },
"finish_reason": "stop"
}
],
"model": "test-model"
})))
.mount(server)
.await;
}
fn openai_client_pointing_at(server_uri: &str, model: &str) -> OpenAiAiClient {
OpenAiAiClient::new(
model.to_string(),
Some("test-key".to_string()),
server_uri.to_string(),
Some(1024),
Some(0.1),
None,
)
.unwrap()
}
#[tokio::test]
async fn send_request_with_options_serializes_response_format_on_the_wire() {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_test_writer()
.try_init();
let server = MockServer::start().await;
mock_chat_completion_ok(&server).await;
let client = openai_client_pointing_at(&server.uri(), "gpt-4o");
let schema = serde_json::json!({
"type": "object",
"properties": { "answer": { "type": "string" } },
"required": ["answer"],
"additionalProperties": false,
});
let options = RequestOptions::default().with_response_schema(schema.clone());
let result = client
.send_request_with_options("system", "user", options)
.await
.unwrap();
assert_eq!(result, "ok");
let received = server.received_requests().await.unwrap();
assert_eq!(
received.len(),
1,
"expected exactly one chat-completions request"
);
let body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap();
assert_eq!(body["response_format"]["type"], "json_schema");
assert_eq!(body["response_format"]["json_schema"]["name"], "response");
assert_eq!(body["response_format"]["json_schema"]["strict"], true);
assert_eq!(body["response_format"]["json_schema"]["schema"], schema);
}
#[tokio::test]
async fn send_request_omits_response_format_on_the_wire() {
let server = MockServer::start().await;
mock_chat_completion_ok(&server).await;
let client = openai_client_pointing_at(&server.uri(), "gpt-4o");
let _ = client.send_request("system", "user").await.unwrap();
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&received[0].body).unwrap();
assert!(
body.get("response_format").is_none(),
"expected response_format to be absent from wire body, got: {body}"
);
}
#[test]
fn build_request_skips_empty_system_prompt() {
let client =
OpenAiAiClient::new_openai("gpt-4o".to_string(), "key".to_string(), None).unwrap();
let request = client.build_request("", "user prompt", None);
assert_eq!(request.messages.len(), 1);
assert_eq!(request.messages[0].role, "user");
assert_eq!(request.messages[0].content, "user prompt");
}
#[tokio::test]
async fn send_request_propagates_network_error_on_unreachable_server() {
let client = openai_client_pointing_at("http://127.0.0.1:1", "gpt-4o");
let err = client
.send_request("system", "user")
.await
.expect_err("expected network error against closed port");
let chain = format!("{err:#}");
assert!(
chain.to_lowercase().contains("network"),
"expected network-error wording in chain, got: {chain}"
);
}
#[tokio::test]
async fn send_request_propagates_http_error_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(500).set_body_string("upstream boom"))
.mount(&server)
.await;
let client = openai_client_pointing_at(&server.uri(), "gpt-4o");
let err = client
.send_request("system", "user")
.await
.expect_err("expected error from 500 response");
let chain = format!("{err:#}");
assert!(
chain.contains("HTTP 500"),
"expected 'HTTP 500' in error chain, got: {chain}"
);
}
#[tokio::test]
async fn send_request_propagates_json_parse_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_string("{not valid json"))
.mount(&server)
.await;
let client = openai_client_pointing_at(&server.uri(), "gpt-4o");
let err = client
.send_request("system", "user")
.await
.expect_err("expected error from malformed JSON body");
let chain = format!("{err:#}");
assert!(
chain.contains("Invalid response format"),
"expected 'Invalid response format' in error chain, got: {chain}"
);
}
#[tokio::test]
async fn send_request_errors_when_response_has_no_choices() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"choices": [],
"model": "test-model"
})))
.mount(&server)
.await;
let client = openai_client_pointing_at(&server.uri(), "gpt-4o");
let err = client
.send_request("system", "user")
.await
.expect_err("expected error when choices array is empty");
let chain = format!("{err:#}");
assert!(
chain.contains("No choices in response"),
"expected 'No choices in response' in error chain, got: {chain}"
);
}
}