use crate::config::{
BackendConfig, BackendKind, Engine, ManagedMode, OpenAiApi as OpenAiApiSurface, Serving,
};
#[path = "backend_probe_token_count.rs"]
mod token_count;
pub use token_count::{count_chat_tokens, TokenCount};
#[derive(Debug)]
struct ProbeHttpStatus(reqwest::StatusCode);
impl std::fmt::Display for ProbeHttpStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "HTTP {}", self.0)
}
}
impl std::error::Error for ProbeHttpStatus {}
#[derive(Debug)]
struct ProbeResponseShape(&'static str);
impl std::fmt::Display for ProbeResponseShape {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid model-list response: missing `{}` array", self.0)
}
}
impl std::error::Error for ProbeResponseShape {}
#[async_trait::async_trait]
pub trait BackendApi: Send + Sync {
async fn list_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>>;
async fn context_window(
&self,
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32>;
fn serving(&self, served_count: usize) -> Serving;
async fn warm_models(
&self,
_client: &reqwest::Client,
_endpoint: &str,
_api_key: Option<&str>,
) -> Option<Vec<String>> {
None
}
}
pub fn api_for(kind: BackendKind) -> &'static dyn BackendApi {
match kind {
BackendKind::Ollama => &OllamaApi,
BackendKind::Openai => &OpenAiApi,
BackendKind::Embedded => &EmbeddedApi,
BackendKind::Anthropic => &AnthropicApi,
}
}
pub fn api_for_engine(kind: BackendKind, engine: Option<Engine>) -> &'static dyn BackendApi {
match (kind, engine) {
(BackendKind::Openai, Some(Engine::LlamaCpp)) => &LlamaCppApi,
(BackendKind::Openai, Some(Engine::Vllm)) => &VllmApi,
_ => api_for(kind),
}
}
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
const MAX_GENERATION_PROBE_BODY_BYTES: usize = 64 * 1024;
async fn read_generation_probe_body(
mut response: reqwest::Response,
) -> Result<(reqwest::StatusCode, Vec<u8>), GenerationFailure> {
let status = response.status();
if response
.content_length()
.is_some_and(|length| length > MAX_GENERATION_PROBE_BODY_BYTES as u64)
{
return Err(GenerationFailure::ResponseTooLarge);
}
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| GenerationFailure::Transport)?
{
if body.len().saturating_add(chunk.len()) > MAX_GENERATION_PROBE_BODY_BYTES {
return Err(GenerationFailure::ResponseTooLarge);
}
body.extend_from_slice(&chunk);
}
Ok((status, body))
}
fn auth_rejection(status: reqwest::StatusCode) -> Option<GenerationCheck> {
matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
)
.then(|| GenerationCheck::Rejected(status.as_u16()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GenerationCheck {
Accepted(Option<OpenAiApiSurface>),
Rejected(u16),
Unverified(GenerationFailure),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenerationFailure {
Transport,
ResponseTooLarge,
HttpStatus(u16),
InvalidJson,
InvalidEnvelope,
InvalidResponsesPayload,
UnsupportedBackend,
}
impl std::fmt::Display for GenerationFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transport => formatter.write_str("the generation request or response failed"),
Self::ResponseTooLarge => formatter.write_str("the generation response was too large"),
Self::HttpStatus(status) => {
write!(
formatter,
"HTTP {status} did not accept the generation request"
)
}
Self::InvalidJson => formatter.write_str("the generation response was not valid JSON"),
Self::InvalidEnvelope => {
formatter.write_str("the generation response had no valid output envelope")
}
Self::InvalidResponsesPayload => {
formatter.write_str("the Responses generation payload was unusable")
}
Self::UnsupportedBackend => {
formatter.write_str("this backend does not use an HTTP generation probe")
}
}
}
}
#[derive(Clone, Copy)]
enum RequiredEnvelope {
OllamaMessage,
OpenAiChoices,
AnthropicContent,
}
fn classify_generation_response(
status: reqwest::StatusCode,
body: &[u8],
required: RequiredEnvelope,
api: Option<OpenAiApiSurface>,
) -> GenerationCheck {
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return GenerationCheck::Rejected(status.as_u16());
}
if !status.is_success() {
return GenerationCheck::Unverified(GenerationFailure::HttpStatus(status.as_u16()));
}
let parsed: serde_json::Value = match serde_json::from_slice(body) {
Ok(value) => value,
Err(_) => {
return GenerationCheck::Unverified(GenerationFailure::InvalidJson);
}
};
let valid = match required {
RequiredEnvelope::OllamaMessage => {
parsed["message"].is_object()
&& (parsed["message"]["content"].is_string()
|| parsed["message"]["tool_calls"].is_array())
}
RequiredEnvelope::OpenAiChoices => parsed["choices"]
.as_array()
.and_then(|choices| choices.first())
.is_some_and(|choice| {
let message = &choice["message"];
message["content"].is_string()
|| message["reasoning"].is_string()
|| message["reasoning_content"].is_string()
|| message["tool_calls"]
.as_array()
.is_some_and(|calls| !calls.is_empty())
}),
RequiredEnvelope::AnthropicContent => parsed["content"]
.as_array()
.is_some_and(|items| !items.is_empty()),
};
if valid {
GenerationCheck::Accepted(api)
} else {
GenerationCheck::Unverified(GenerationFailure::InvalidEnvelope)
}
}
fn openai_chat_probe_body(model: &str, modern_budget: bool) -> serde_json::Value {
let mut body = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": "Reply with OK."}],
"stream": false,
});
let budget = if modern_budget {
"max_completion_tokens"
} else {
"max_tokens"
};
body[budget] = serde_json::json!(8);
body
}
fn rejects_legacy_max_tokens(status: reqwest::StatusCode, body: &[u8]) -> bool {
if !matches!(status.as_u16(), 400 | 422) {
return false;
}
let text = String::from_utf8_lossy(body).to_ascii_lowercase();
text.contains("max_tokens")
&& [
"unsupported",
"not supported",
"unknown",
"unrecognized",
"unexpected",
"deprecated",
]
.iter()
.any(|needle| text.contains(needle))
}
async fn send_openai_chat_probe(
client: &reqwest::Client,
base: &str,
model: &str,
api_key: Option<&str>,
modern_budget: bool,
) -> Result<(reqwest::StatusCode, Vec<u8>), GenerationCheck> {
let mut request = client
.post(format!("{base}/v1/chat/completions"))
.json(&openai_chat_probe_body(model, modern_budget));
if let Some(key) = api_key.filter(|key| !key.trim().is_empty()) {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|_| GenerationCheck::Unverified(GenerationFailure::Transport))?;
if let Some(rejected) = auth_rejection(response.status()) {
return Err(rejected);
}
read_generation_probe_body(response)
.await
.map_err(GenerationCheck::Unverified)
}
fn classify_responses_generation(status: reqwest::StatusCode, body: &[u8]) -> GenerationCheck {
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return GenerationCheck::Rejected(status.as_u16());
}
if !status.is_success() {
return GenerationCheck::Unverified(GenerationFailure::HttpStatus(status.as_u16()));
}
let parsed: serde_json::Value = match serde_json::from_slice(body) {
Ok(value) => value,
Err(_) => {
return GenerationCheck::Unverified(GenerationFailure::InvalidJson);
}
};
let mut decodable = parsed.clone();
if parsed["status"] == "incomplete"
&& parsed["incomplete_details"]["reason"] == "max_output_tokens"
{
decodable["status"] = serde_json::Value::String("completed".into());
}
match crate::responses_wire::decode_response(&decodable) {
Ok(_) => GenerationCheck::Accepted(Some(OpenAiApiSurface::Responses)),
Err(_) => GenerationCheck::Unverified(GenerationFailure::InvalidResponsesPayload),
}
}
async fn send_responses_generation_probe(
request: reqwest::RequestBuilder,
api_key: Option<&str>,
) -> GenerationCheck {
let request = match api_key.filter(|key| !key.trim().is_empty()) {
Some(key) => request.bearer_auth(key),
None => request,
};
match request.send().await {
Ok(response) => {
if let Some(rejected) = auth_rejection(response.status()) {
return rejected;
}
match read_generation_probe_body(response).await {
Ok((status, body)) => classify_responses_generation(status, &body),
Err(error) => GenerationCheck::Unverified(error),
}
}
Err(_) => GenerationCheck::Unverified(GenerationFailure::Transport),
}
}
async fn send_generation_probe(
request: reqwest::RequestBuilder,
api_key: Option<&str>,
required: RequiredEnvelope,
api: Option<OpenAiApiSurface>,
) -> GenerationCheck {
let request = match api_key.filter(|key| !key.trim().is_empty()) {
Some(key) => request.bearer_auth(key),
None => request,
};
match request.send().await {
Ok(response) => {
if let Some(rejected) = auth_rejection(response.status()) {
return rejected;
}
match read_generation_probe_body(response).await {
Ok((status, body)) => classify_generation_response(status, &body, required, api),
Err(error) => GenerationCheck::Unverified(error),
}
}
Err(_) => GenerationCheck::Unverified(GenerationFailure::Transport),
}
}
pub async fn verify_generation(
client: &reqwest::Client,
kind: BackendKind,
api: Option<OpenAiApiSurface>,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> GenerationCheck {
let base = endpoint.trim_end_matches('/');
match kind {
BackendKind::Ollama => {
let body = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": "hello"}],
"stream": false,
"options": {"num_predict": 1},
});
send_generation_probe(
client.post(format!("{base}/api/chat")).json(&body),
api_key,
RequiredEnvelope::OllamaMessage,
None,
)
.await
}
BackendKind::Openai => {
if api != Some(OpenAiApiSurface::Responses) {
let (mut status, mut body) =
match send_openai_chat_probe(client, base, model, api_key, false).await {
Ok(result) => result,
Err(result) => return result,
};
if rejects_legacy_max_tokens(status, &body) {
(status, body) =
match send_openai_chat_probe(client, base, model, api_key, true).await {
Ok(result) => result,
Err(result) => return result,
};
}
if status.is_success() || api == Some(OpenAiApiSurface::ChatCompletions) {
return classify_generation_response(
status,
&body,
RequiredEnvelope::OpenAiChoices,
Some(OpenAiApiSurface::ChatCompletions),
);
}
let text = String::from_utf8_lossy(&body);
if status != reqwest::StatusCode::NOT_FOUND && !is_responses_only_error(&text) {
return classify_generation_response(
status,
&body,
RequiredEnvelope::OpenAiChoices,
Some(OpenAiApiSurface::ChatCompletions),
);
}
}
let body = crate::responses_wire::generation_probe_body(model);
send_responses_generation_probe(
client.post(format!("{base}/v1/responses")).json(&body),
api_key,
)
.await
}
BackendKind::Anthropic => {
let body = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 1,
});
let request = client
.post(format!("{base}/v1/messages"))
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&body);
let request = match api_key.filter(|key| !key.trim().is_empty()) {
Some(key) => request.header("x-api-key", key),
None => request,
};
match request.send().await {
Ok(response) => {
if let Some(rejected) = auth_rejection(response.status()) {
return rejected;
}
match read_generation_probe_body(response).await {
Ok((status, body)) => classify_generation_response(
status,
&body,
RequiredEnvelope::AnthropicContent,
None,
),
Err(error) => GenerationCheck::Unverified(error),
}
}
Err(_) => GenerationCheck::Unverified(GenerationFailure::Transport),
}
}
BackendKind::Embedded => GenerationCheck::Unverified(GenerationFailure::UnsupportedBackend),
}
}
pub struct OllamaApi;
pub struct OpenAiApi;
pub struct EmbeddedApi;
pub struct AnthropicApi;
pub struct LlamaCppApi;
pub struct VllmApi;
fn maybe_bearer(req: reqwest::RequestBuilder, api_key: Option<&str>) -> reqwest::RequestBuilder {
match api_key.filter(|k| !k.trim().is_empty()) {
Some(key) => req.bearer_auth(key),
None => req,
}
}
#[async_trait::async_trait]
impl BackendApi for OllamaApi {
async fn list_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let url = format!("{}/api/tags", endpoint.trim_end_matches('/'));
let resp = maybe_bearer(client.get(&url), api_key).send().await?;
if !resp.status().is_success() {
return Err(ProbeHttpStatus(resp.status()).into());
}
let json: serde_json::Value = resp.json().await?;
let models = json["models"]
.as_array()
.ok_or(ProbeResponseShape("models"))?;
Ok(models
.iter()
.filter_map(|m| m["name"].as_str().map(str::to_string))
.collect())
}
async fn context_window(
&self,
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32> {
let url = format!("{}/api/show", endpoint.trim_end_matches('/'));
let resp = maybe_bearer(
client
.post(&url)
.json(&serde_json::json!({ "name": model })),
api_key,
)
.send()
.await
.ok()?;
let json: serde_json::Value = resp.json().await.ok()?;
parse_ollama_show_window(&json)
}
fn serving(&self, _served_count: usize) -> Serving {
Serving::Multiplexer
}
async fn warm_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> Option<Vec<String>> {
let ps = fetch_ollama_ps(client, endpoint, api_key).await.ok()?;
Some(ps.into_iter().map(|m| m.name).collect())
}
}
#[async_trait::async_trait]
impl BackendApi for LlamaCppApi {
async fn list_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
OpenAiApi.list_models(client, endpoint, api_key).await
}
async fn context_window(
&self,
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32> {
OpenAiApi
.context_window(client, endpoint, model, api_key)
.await
}
fn serving(&self, served_count: usize) -> Serving {
OpenAiApi.serving(served_count)
}
async fn warm_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> Option<Vec<String>> {
let states = fetch_llamacpp_model_states(client, endpoint, api_key)
.await
.ok()??;
Some(
states
.into_iter()
.filter(|(_, state)| state == "loaded")
.map(|(name, _)| name)
.collect(),
)
}
}
#[async_trait::async_trait]
impl BackendApi for VllmApi {
async fn list_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
OpenAiApi.list_models(client, endpoint, api_key).await
}
async fn context_window(
&self,
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32> {
OpenAiApi
.context_window(client, endpoint, model, api_key)
.await
}
fn serving(&self, served_count: usize) -> Serving {
OpenAiApi.serving(served_count)
}
async fn warm_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> Option<Vec<String>> {
self.list_models(client, endpoint, api_key).await.ok()
}
}
#[async_trait::async_trait]
impl BackendApi for OpenAiApi {
async fn list_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let json = openai_models_json(client, endpoint, api_key).await?;
let models = json["data"].as_array().ok_or(ProbeResponseShape("data"))?;
Ok(models
.iter()
.filter_map(|m| m["id"].as_str().map(str::to_string))
.collect())
}
async fn context_window(
&self,
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32> {
if let Ok(json) = openai_models_json(client, endpoint, api_key).await {
if let Some(window) = parse_openai_models_window(&json, model) {
return Some(window);
}
}
llamacpp_served_window(client, endpoint, model, api_key).await
}
fn serving(&self, served_count: usize) -> Serving {
if served_count == 1 {
Serving::Instance
} else {
Serving::Multiplexer
}
}
}
#[async_trait::async_trait]
impl BackendApi for EmbeddedApi {
async fn list_models(
&self,
_client: &reqwest::Client,
_endpoint: &str,
_api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
async fn context_window(
&self,
_client: &reqwest::Client,
_endpoint: &str,
_model: &str,
_api_key: Option<&str>,
) -> Option<u32> {
None
}
fn serving(&self, _served_count: usize) -> Serving {
Serving::Instance
}
}
#[async_trait::async_trait]
impl BackendApi for AnthropicApi {
async fn list_models(
&self,
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let entries = anthropic_models_entries(client, endpoint, api_key).await?;
Ok(entries
.iter()
.filter_map(|m| m["id"].as_str().map(str::to_string))
.collect())
}
async fn context_window(
&self,
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32> {
let entries = anthropic_models_entries(client, endpoint, api_key)
.await
.ok()?;
entries
.iter()
.find(|m| m["id"].as_str() == Some(model))
.and_then(|m| m["max_input_tokens"].as_u64())
.and_then(|w| u32::try_from(w).ok())
}
fn serving(&self, _served_count: usize) -> Serving {
Serving::Multiplexer
}
}
const ANTHROPIC_MODELS_PAGE_CAP: usize = 5;
async fn anthropic_models_entries(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<serde_json::Value>> {
let base = format!("{}/v1/models?limit=100", endpoint.trim_end_matches('/'));
let mut entries = Vec::new();
let mut after_id: Option<String> = None;
for _ in 0..ANTHROPIC_MODELS_PAGE_CAP {
let url = match &after_id {
Some(id) => format!("{base}&after_id={id}"),
None => base.clone(),
};
let mut req = client
.get(&url)
.header("anthropic-version", ANTHROPIC_VERSION);
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
req = req.header("x-api-key", key);
}
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(ProbeHttpStatus(resp.status()).into());
}
let json: serde_json::Value = resp.json().await?;
let page = json["data"].as_array().ok_or(ProbeResponseShape("data"))?;
entries.extend(page.iter().cloned());
let has_more = json["has_more"].as_bool().unwrap_or(false);
after_id = json["last_id"].as_str().map(str::to_string);
if !has_more || after_id.is_none() {
break;
}
}
Ok(entries)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FingerprintMarker {
HasKey(&'static str),
HasAnyKey(&'static [&'static str]),
ModelsArrayWithState,
}
#[derive(Debug, Clone, Copy)]
pub struct EngineFingerprint {
pub engine: Engine,
pub path: &'static str,
pub marker: FingerprintMarker,
}
pub fn builtin_engine_fingerprints() -> &'static [EngineFingerprint] {
&[
EngineFingerprint {
engine: Engine::LlamaCpp,
path: "/props",
marker: FingerprintMarker::HasAnyKey(&["default_generation_settings", "model_path"]),
},
EngineFingerprint {
engine: Engine::Vllm,
path: "/version",
marker: FingerprintMarker::HasKey("version"),
},
EngineFingerprint {
engine: Engine::LlamaCpp,
path: "/models",
marker: FingerprintMarker::ModelsArrayWithState,
},
]
}
#[must_use]
pub fn served_context_window(json: &serde_json::Value) -> Option<u32> {
json.get("default_generation_settings")
.and_then(|g| g.get("n_ctx"))
.or_else(|| json.get("n_ctx"))
.and_then(serde_json::Value::as_u64)
.filter(|n| *n > 0)
.map(|n| u32::try_from(n).unwrap_or(u32::MAX))
}
async fn llamacpp_served_window(
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> Option<u32> {
let url = format!("{}/props", endpoint.trim_end_matches('/'));
let resp = maybe_bearer(client.get(&url).query(&[("model", model)]), api_key)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
served_context_window(&resp.json().await.ok()?)
}
pub fn fingerprint_matches(marker: &FingerprintMarker, json: &serde_json::Value) -> bool {
match marker {
FingerprintMarker::HasKey(key) => json.get(key).is_some(),
FingerprintMarker::HasAnyKey(keys) => keys.iter().any(|k| json.get(k).is_some()),
FingerprintMarker::ModelsArrayWithState => {
let entries = json["data"].as_array().or_else(|| json.as_array());
entries.is_some_and(|arr| {
!arr.is_empty()
&& arr
.iter()
.all(|e| e.get("state").is_some() || e.get("status").is_some())
})
}
}
}
pub async fn detect_engine(
client: &reqwest::Client,
endpoint: &str,
kind: BackendKind,
api_key: Option<&str>,
) -> Option<Engine> {
match kind {
BackendKind::Ollama => return Some(Engine::Ollama),
BackendKind::Openai => {}
BackendKind::Embedded | BackendKind::Anthropic => return None,
}
let base = endpoint.trim_end_matches('/');
for fp in builtin_engine_fingerprints() {
let url = format!("{base}{}", fp.path);
let Ok(resp) = maybe_bearer(client.get(&url), api_key).send().await else {
continue;
};
if !resp.status().is_success() {
continue;
}
let Ok(json) = resp.json::<serde_json::Value>().await else {
continue;
};
if fingerprint_matches(&fp.marker, &json) {
return Some(fp.engine);
}
}
None
}
#[derive(Debug, Clone, PartialEq)]
pub struct PsModel {
pub name: String,
pub size_bytes: Option<u64>,
pub size_vram_bytes: Option<u64>,
pub expires_at: Option<String>,
}
pub fn parse_ollama_ps(json: &serde_json::Value) -> Vec<PsModel> {
json["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| {
Some(PsModel {
name: m["name"].as_str()?.to_string(),
size_bytes: m["size"].as_u64(),
size_vram_bytes: m["size_vram"].as_u64(),
expires_at: m["expires_at"].as_str().map(str::to_string),
})
})
.collect()
})
.unwrap_or_default()
}
pub async fn fetch_ollama_ps(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<PsModel>> {
let url = format!("{}/api/ps", endpoint.trim_end_matches('/'));
let resp = maybe_bearer(client.get(&url), api_key).send().await?;
if !resp.status().is_success() {
return Err(ProbeHttpStatus(resp.status()).into());
}
let json: serde_json::Value = resp.json().await?;
Ok(parse_ollama_ps(&json))
}
pub async fn fetch_llamacpp_model_states(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<Option<Vec<(String, String)>>> {
let url = format!("{}/models", endpoint.trim_end_matches('/'));
let response = maybe_bearer(client.get(url), api_key).send().await?;
if !response.status().is_success() {
return Err(ProbeHttpStatus(response.status()).into());
}
Ok(parse_llamacpp_model_states(&response.json().await?))
}
pub async fn set_llamacpp_model_loaded(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
model: &str,
loaded: bool,
) -> anyhow::Result<()> {
let action = if loaded { "load" } else { "unload" };
let url = format!("{}/models/{action}", endpoint.trim_end_matches('/'));
let response = maybe_bearer(
client.post(url).json(&serde_json::json!({"model": model})),
api_key,
)
.send()
.await?;
if !response.status().is_success() {
return Err(ProbeHttpStatus(response.status()).into());
}
let body: serde_json::Value = response.json().await?;
anyhow::ensure!(
body["success"].as_bool() == Some(true),
"server did not confirm model {action}"
);
Ok(())
}
pub fn parse_llamacpp_model_states(json: &serde_json::Value) -> Option<Vec<(String, String)>> {
let entries = json["data"].as_array().or_else(|| json.as_array())?;
let state_of = |entry: &serde_json::Value| {
entry["state"]
.as_str()
.or_else(|| entry["status"].as_str())
.or_else(|| entry["status"]["value"].as_str())
.map(str::to_ascii_lowercase)
};
if !entries.iter().any(|entry| state_of(entry).is_some()) {
return None;
}
Some(
entries
.iter()
.filter_map(|entry| {
let name = entry["id"].as_str().or_else(|| entry["model"].as_str())?;
let raw = state_of(entry).unwrap_or_default();
let state = if entry["status"]["failed"].as_bool() == Some(true) {
"failed"
} else {
match raw.as_str() {
"loaded" | "unloaded" | "loading" | "sleeping" | "downloading" => {
raw.as_str()
}
_ => "unknown",
}
};
Some((name.to_string(), state.to_string()))
})
.collect(),
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LlamaCppLaunch {
pub args: Vec<String>,
pub preset: Option<String>,
}
impl LlamaCppLaunch {
#[must_use]
pub fn flag(&self, name: &str) -> Option<&str> {
let at = self.args.iter().position(|arg| arg == name)?;
self.args.get(at + 1).map(String::as_str)
}
}
pub fn parse_llamacpp_launch(json: &serde_json::Value, model: &str) -> Option<LlamaCppLaunch> {
let entries = json["data"].as_array().or_else(|| json.as_array())?;
let status = &entries
.iter()
.find(|entry| entry["id"].as_str().or_else(|| entry["model"].as_str()) == Some(model))?
["status"];
let args: Vec<String> = status["args"]
.as_array()
.map(|argv| {
argv.iter()
.skip(1)
.filter_map(|a| a.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let preset = status["preset"].as_str().map(str::to_string);
(!args.is_empty() || preset.is_some()).then_some(LlamaCppLaunch { args, preset })
}
pub async fn fetch_llamacpp_launch(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
model: &str,
) -> anyhow::Result<Option<LlamaCppLaunch>> {
let url = format!("{}/models", endpoint.trim_end_matches('/'));
let response = maybe_bearer(client.get(url), api_key).send().await?;
if !response.status().is_success() {
return Err(ProbeHttpStatus(response.status()).into());
}
Ok(parse_llamacpp_launch(&response.json().await?, model))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchProbe {
Declared(LlamaCppLaunch),
Absent,
Unsupported,
Refused(u16),
TimedOut,
Failed(String),
}
impl LaunchProbe {
#[must_use]
pub fn from_result(result: anyhow::Result<Option<LlamaCppLaunch>>) -> Self {
let error = match result {
Ok(Some(launch)) => return Self::Declared(launch),
Ok(None) => return Self::Absent,
Err(error) => error,
};
if let Some(ProbeHttpStatus(status)) = error.downcast_ref::<ProbeHttpStatus>() {
return match status.as_u16() {
401 | 403 => Self::Refused(status.as_u16()),
404 | 405 | 501 => Self::Unsupported,
other => Self::Failed(format!("HTTP {other}")),
};
}
match error.downcast_ref::<reqwest::Error>() {
Some(e) if e.is_timeout() => Self::TimedOut,
Some(e) if e.is_decode() => Self::Unsupported,
Some(e) if e.is_connect() => Self::Failed("could not connect".to_string()),
_ => Self::Failed(error.to_string()),
}
}
}
pub fn parse_llamacpp_models_warm(json: &serde_json::Value) -> Option<Vec<String>> {
Some(
parse_llamacpp_model_states(json)?
.into_iter()
.filter(|(_, state)| state == "loaded")
.map(|(name, _)| name)
.collect(),
)
}
async fn openai_models_json(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let url = format!("{}/v1/models", endpoint.trim_end_matches('/'));
let mut req = client.get(&url);
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
req = req.bearer_auth(key);
}
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(ProbeHttpStatus(resp.status()).into());
}
Ok(resp.json().await?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointProbeResult {
pub endpoint: String,
pub kind: BackendKind,
pub models: Vec<String>,
pub serving: Serving,
pub engine: Option<Engine>,
pub warm: Vec<String>,
}
#[derive(Debug)]
struct ProbeFailure {
status: Option<reqwest::StatusCode>,
reached_http_service: bool,
detail: String,
}
impl ProbeFailure {
fn from_error(error: anyhow::Error) -> Self {
if let Some(status) = error.downcast_ref::<ProbeHttpStatus>() {
return Self {
status: Some(status.0),
reached_http_service: true,
detail: error.to_string(),
};
}
if error.downcast_ref::<ProbeResponseShape>().is_some() {
return Self {
status: None,
reached_http_service: true,
detail: error.to_string(),
};
}
let reached_http_service = error
.downcast_ref::<reqwest::Error>()
.is_some_and(|error| error.is_decode() || error.status().is_some());
Self {
status: None,
reached_http_service,
detail: error.to_string(),
}
}
fn is_auth_failure(&self) -> bool {
matches!(
self.status,
Some(reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN)
)
}
fn authentication_error_for(
&self,
endpoint: &str,
supplied_key: bool,
protocol: &str,
probe: &str,
) -> Option<anyhow::Error> {
if !self.is_auth_failure() {
return None;
}
if supplied_key {
Some(anyhow::anyhow!(
"authentication rejected by {protocol} inference endpoint {endpoint} \
({probe} returned {}); check the bearer token",
self.detail
))
} else {
Some(anyhow::anyhow!(
"authentication required by {protocol} inference endpoint {endpoint} \
({probe} returned {}); supply a bearer token",
self.detail
))
}
}
fn authentication_error(&self, endpoint: &str, supplied_key: bool) -> Option<anyhow::Error> {
self.authentication_error_for(
endpoint,
supplied_key,
"OpenAI-compatible",
"GET /v1/models",
)
}
}
pub async fn detect_endpoint(
client: &reqwest::Client,
endpoint: &str,
api_key: Option<&str>,
) -> anyhow::Result<EndpointProbeResult> {
let endpoint = endpoint.trim_end_matches('/');
let ollama_api = api_for(BackendKind::Ollama);
let openai_api = api_for(BackendKind::Openai);
let (ollama, openai) = tokio::join!(
ollama_api.list_models(client, endpoint, api_key),
openai_api.list_models(client, endpoint, api_key),
);
let supplied_key = api_key.is_some_and(|key| !key.trim().is_empty());
match (ollama, openai) {
(Ok(ollama_models), Ok(openai_models))
if ollama_models.is_empty() && !openai_models.is_empty() =>
{
Ok(finish_probe(
client,
endpoint,
BackendKind::Openai,
openai_models,
api_key,
)
.await)
}
(Ok(models), Err(openai_error)) if models.is_empty() => {
let openai = ProbeFailure::from_error(openai_error);
if let Some(error) = openai.authentication_error(endpoint, supplied_key) {
return Err(error);
}
Ok(finish_probe(client, endpoint, BackendKind::Ollama, models, api_key).await)
}
(Ok(models), _) => {
Ok(finish_probe(client, endpoint, BackendKind::Ollama, models, api_key).await)
}
(Err(_), Ok(models)) => {
Ok(finish_probe(client, endpoint, BackendKind::Openai, models, api_key).await)
}
(Err(ollama_error), Err(openai_error)) => {
let ollama = ProbeFailure::from_error(ollama_error);
let openai = ProbeFailure::from_error(openai_error);
if let Some(error) = openai.authentication_error(endpoint, supplied_key) {
return Err(error);
}
if let Some(error) =
ollama.authentication_error_for(endpoint, supplied_key, "Ollama", "GET /api/tags")
{
return Err(error);
}
if ollama.reached_http_service || openai.reached_http_service {
anyhow::bail!(
"unsupported inference endpoint {endpoint}: neither Ollama GET /api/tags nor \
OpenAI-compatible GET /v1/models succeeded \
(Ollama: {}; OpenAI-compatible: {})",
ollama.detail,
openai.detail
);
}
anyhow::bail!(
"unreachable inference endpoint {endpoint}: both protocol probes failed \
(Ollama GET /api/tags: {}; OpenAI-compatible GET /v1/models: {})",
ollama.detail,
openai.detail
);
}
}
}
async fn finish_probe(
client: &reqwest::Client,
endpoint: &str,
kind: BackendKind,
models: Vec<String>,
api_key: Option<&str>,
) -> EndpointProbeResult {
let serving = api_for(kind).serving(models.len());
let engine = detect_engine(client, endpoint, kind, api_key).await;
let warm = match engine {
Some(Engine::Vllm) => models.clone(),
_ => api_for_engine(kind, engine)
.warm_models(client, endpoint, api_key)
.await
.unwrap_or_default(),
};
EndpointProbeResult {
endpoint: endpoint.to_string(),
kind,
models,
serving,
engine,
warm,
}
}
pub fn is_responses_only_error(body: &str) -> bool {
let lower = body.to_ascii_lowercase();
lower.contains("v1/responses")
&& (lower.contains("only supported")
|| lower.contains("only available")
|| lower.contains("unsupported_api")
|| lower.contains("not supported"))
}
pub async fn detect_openai_api(
client: &reqwest::Client,
endpoint: &str,
model: &str,
api_key: Option<&str>,
) -> anyhow::Result<OpenAiApiSurface> {
let base = endpoint.trim_end_matches('/');
let chat_url = format!("{base}/v1/chat/completions");
let chat_body = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_completion_tokens": 1,
"stream": false,
"tools": [{
"type": "function",
"function": {
"name": "probe_noop",
"description": "capability probe — never called",
"parameters": {"type": "object", "properties": {}},
},
}],
});
let mut req = client.post(&chat_url).json(&chat_body);
if let Some(key) = api_key.filter(|k| !k.trim().is_empty()) {
req = req.bearer_auth(key);
}
let resp = req.send().await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if status.is_success() {
return Ok(OpenAiApiSurface::ChatCompletions);
}
if matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
anyhow::bail!(
"authentication rejected while detecting the OpenAI API surface (HTTP {status})"
);
}
if is_responses_only_error(&body) {
return Ok(OpenAiApiSurface::Responses);
}
if status != reqwest::StatusCode::NOT_FOUND {
return Ok(OpenAiApiSurface::ChatCompletions);
}
let responses_url = format!("{base}/v1/responses");
let responses_body = crate::responses_wire::generation_probe_body(model);
let mut req = client.post(&responses_url).json(&responses_body);
if let Some(key) = api_key.filter(|k| !k.trim().is_empty()) {
req = req.bearer_auth(key);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
anyhow::bail!(
"authentication rejected while detecting the OpenAI API surface (HTTP {status})"
);
}
if status.is_success()
|| status != reqwest::StatusCode::NOT_FOUND
|| is_responses_only_error(&body)
{
Ok(OpenAiApiSurface::Responses)
} else {
Ok(OpenAiApiSurface::ChatCompletions)
}
}
Err(_) => Ok(OpenAiApiSurface::ChatCompletions),
}
}
pub fn parse_ollama_show_window(json: &serde_json::Value) -> Option<u32> {
let arch_limit: Option<u32> = json["model_info"].as_object().and_then(|info| {
if let Some(v) = info.get("context_length").and_then(|v| v.as_u64()) {
return Some(v as u32);
}
info.iter()
.filter(|(k, _)| k.ends_with(".context_length"))
.filter_map(|(_, v)| v.as_u64())
.map(|v| v as u32)
.min()
});
let modelfile_ctx: Option<u32> = json["parameters"].as_str().and_then(|params| {
params.lines().find_map(|line| {
let mut parts = line.split_whitespace();
if parts.next()? == "num_ctx" {
parts.next()?.parse::<u32>().ok()
} else {
None
}
})
});
match (arch_limit, modelfile_ctx) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
pub fn parse_openai_models_window(json: &serde_json::Value, model: &str) -> Option<u32> {
let data = json["data"].as_array()?;
let window_of = |e: &serde_json::Value| {
[
"max_model_len",
"context_window",
"context_length",
"max_input_tokens",
]
.into_iter()
.find_map(|field| e[field].as_u64())
.and_then(|value| u32::try_from(value).ok())
};
if let Some(entry) = data.iter().find(|e| e["id"].as_str() == Some(model)) {
return window_of(entry).filter(|n| *n > 0);
}
(data.len() == 1)
.then(|| window_of(&data[0]).filter(|n| *n > 0))
.flatten()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Served {
pub models: Vec<String>,
pub warm: Vec<String>,
}
impl Served {
pub fn from_models(models: Vec<String>) -> Self {
Self {
models,
warm: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Adoption {
pub model: Option<String>,
pub serving: Serving,
pub requested_ignored: bool,
pub requested_unavailable: bool,
pub declared_unavailable: bool,
pub adopted_warm: bool,
pub pin_conflict: Option<String>,
pub ambiguous_warm: Vec<String>,
}
pub fn adopt(backend: &BackendConfig, served: &Served, requested: Option<&str>) -> Adoption {
let serving = backend.serving.unwrap_or_else(|| {
let kind = backend.kind.unwrap_or(BackendKind::Ollama);
api_for(kind).serving(served.models.len())
});
match serving {
Serving::Instance => {
let adopted = served.models.first().cloned();
let asked = requested
.map(str::to_string)
.or_else(|| backend.effective_model().map(str::to_string));
let requested_ignored = match (&adopted, &asked) {
(Some(a), Some(r)) => a != r,
(None, _) => false,
(_, None) => false,
};
Adoption {
model: adopted.or(asked),
serving,
requested_ignored,
requested_unavailable: false,
declared_unavailable: false,
adopted_warm: false,
pin_conflict: None,
ambiguous_warm: Vec::new(),
}
}
Serving::Multiplexer => {
let is_served = |candidate: &str| {
served.models.is_empty() || served.models.iter().any(|m| m == candidate)
};
let requested_ok = requested.map(is_served);
let requested_unavailable = requested_ok == Some(false);
let declared = backend.effective_model();
let pin: Option<String> = requested
.filter(|_| requested_ok == Some(true))
.or_else(|| declared.filter(|d| is_served(d)))
.map(str::to_string);
let declared_unavailable =
requested_ok != Some(true) && declared.is_some_and(|d| !is_served(d));
let warm_candidates: Vec<String> = served
.warm
.iter()
.filter(|w| served.models.contains(w))
.cloned()
.collect();
let warm: Option<String> = warm_candidates.first().cloned();
let shared = backend.managed == Some(ManagedMode::Shared);
let pin_is_warm = pin
.as_deref()
.is_some_and(|p| warm_candidates.iter().any(|w| w == p));
let (model, adopted_warm, pin_conflict, ambiguous_warm) =
if pin.is_none() && warm_candidates.len() > 1 {
(None, false, None, warm_candidates)
} else if shared && !pin_is_warm {
if let Some(w) = warm.clone() {
let conflict = pin.clone().filter(|p| p != &w);
(Some(w), true, conflict, Vec::new())
} else {
let model = pin.or_else(|| served.models.first().cloned());
(model, false, None, Vec::new())
}
} else {
let model = pin.or(warm).or_else(|| served.models.first().cloned());
(model, false, None, Vec::new())
};
Adoption {
model,
serving,
requested_ignored: false,
requested_unavailable,
declared_unavailable,
adopted_warm,
pin_conflict,
ambiguous_warm,
}
}
}
}
pub async fn fetch_ollama_models(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<Vec<String>> {
OllamaApi.list_models(client, url, None).await
}
pub async fn fetch_openai_models_auth(
client: &reqwest::Client,
url: &str,
api_key: Option<&str>,
) -> anyhow::Result<Vec<String>> {
OpenAiApi.list_models(client, url, api_key).await
}
pub async fn fetch_openai_models(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<Vec<String>> {
fetch_openai_models_auth(client, url, None).await
}
#[cfg(test)]
#[path = "backend_probe_tests/mod.rs"]
mod tests;