use crate::config::{BackendConfig, BackendKind, OpenAiApi as OpenAiApiSurface, Serving};
#[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;
}
pub fn api_for(kind: BackendKind) -> &'static dyn BackendApi {
match kind {
BackendKind::Ollama => &OllamaApi,
BackendKind::Openai => &OpenAiApi,
BackendKind::Embedded => &EmbeddedApi,
}
}
pub struct OllamaApi;
pub struct OpenAiApi;
pub struct EmbeddedApi;
#[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 = client.get(&url).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 = client
.post(&url)
.json(&serde_json::json!({ "name": model }))
.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_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> {
let json = openai_models_json(client, endpoint, api_key).await.ok()?;
parse_openai_models_window(&json, model)
}
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 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,
}
#[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(&self, endpoint: &str, supplied_key: bool) -> Option<anyhow::Error> {
if !self.is_auth_failure() {
return None;
}
if supplied_key {
Some(anyhow::anyhow!(
"authentication rejected by OpenAI-compatible inference endpoint {endpoint} \
(GET /v1/models returned {}); check the bearer token",
self.detail
))
} else {
Some(anyhow::anyhow!(
"authentication required by OpenAI-compatible inference endpoint {endpoint} \
(GET /v1/models returned {}); supply a bearer token",
self.detail
))
}
}
}
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, None),
openai_api.list_models(client, endpoint, api_key),
);
match (ollama, openai) {
(Ok(ollama_models), Ok(openai_models))
if ollama_models.is_empty() && !openai_models.is_empty() =>
{
Ok(EndpointProbeResult {
endpoint: endpoint.to_string(),
kind: BackendKind::Openai,
serving: openai_api.serving(openai_models.len()),
models: openai_models,
})
}
(Ok(models), Err(openai_error)) if models.is_empty() => {
let openai = ProbeFailure::from_error(openai_error);
if let Some(error) = openai
.authentication_error(endpoint, api_key.is_some_and(|key| !key.trim().is_empty()))
{
return Err(error);
}
Ok(EndpointProbeResult {
endpoint: endpoint.to_string(),
kind: BackendKind::Ollama,
serving: ollama_api.serving(models.len()),
models,
})
}
(Ok(models), _) => Ok(EndpointProbeResult {
endpoint: endpoint.to_string(),
kind: BackendKind::Ollama,
serving: ollama_api.serving(models.len()),
models,
}),
(Err(_), Ok(models)) => Ok(EndpointProbeResult {
endpoint: endpoint.to_string(),
kind: BackendKind::Openai,
serving: openai_api.serving(models.len()),
models,
}),
(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, api_key.is_some_and(|key| !key.trim().is_empty()))
{
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
);
}
}
}
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"))
}
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_tokens": 1,
"stream": false,
});
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 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 = serde_json::json!({
"model": model,
"input": "ping",
"max_output_tokens": 1,
});
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 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| e["max_model_len"].as_u64().map(|v| v as u32);
if let Some(w) = data
.iter()
.find(|e| e["id"].as_str() == Some(model))
.and_then(&window_of)
{
return Some(w);
}
data.iter().find_map(&window_of)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Served {
pub models: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Adoption {
pub model: Option<String>,
pub serving: Serving,
pub requested_ignored: bool,
pub requested_unavailable: bool,
}
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,
}
}
Serving::Multiplexer => {
let requested_ok =
requested.map(|r| served.models.is_empty() || served.models.iter().any(|m| m == r));
let requested_unavailable = requested_ok == Some(false);
let model = requested
.filter(|_| requested_ok == Some(true))
.map(str::to_string)
.or_else(|| backend.effective_model().map(str::to_string))
.or_else(|| served.models.first().cloned());
Adoption {
model,
serving,
requested_ignored: false,
requested_unavailable,
}
}
}
}
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)]
mod tests {
use super::*;
use crate::config::{BackendKind, OpenAiApi as OpenAiApiSurface};
use std::time::Duration;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn openai_backend(model: Option<&str>, serving: Option<Serving>) -> BackendConfig {
BackendConfig {
name: "b".into(),
endpoint: "http://h:8000".into(),
model: model.map(str::to_string),
kind: Some(BackendKind::Openai),
serving,
..Default::default()
}
}
fn served(models: &[&str]) -> Served {
Served {
models: models.iter().map(|m| m.to_string()).collect(),
}
}
#[tokio::test]
async fn detect_endpoint_prefers_ollama_when_both_protocols_answer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": [{"name": "qwen3:30b"}, {"name": "llama3.1:8b"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "openai-shim-model"}]
})))
.mount(&server)
.await;
let result = detect_endpoint(&reqwest::Client::new(), &format!("{}/", server.uri()), None)
.await
.unwrap();
assert_eq!(result.endpoint, server.uri());
assert_eq!(result.kind, BackendKind::Ollama);
assert_eq!(result.models, vec!["qwen3:30b", "llama3.1:8b"]);
assert_eq!(result.serving, Serving::Multiplexer);
}
#[tokio::test]
async fn detect_endpoint_prefers_the_nonempty_openai_surface() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": []
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "served-model"}]
})))
.mount(&server)
.await;
let result = detect_endpoint(&reqwest::Client::new(), &server.uri(), None)
.await
.unwrap();
assert_eq!(result.kind, BackendKind::Openai);
assert_eq!(result.models, vec!["served-model"]);
assert_eq!(result.serving, Serving::Instance);
}
#[tokio::test]
async fn detect_endpoint_finds_authenticated_openai_instance() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer secret-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "ornith-35b"}]
})))
.mount(&server)
.await;
let result = detect_endpoint(&reqwest::Client::new(), &server.uri(), Some("secret-token"))
.await
.unwrap();
assert_eq!(result.kind, BackendKind::Openai);
assert_eq!(result.models, vec!["ornith-35b"]);
assert_eq!(result.serving, Serving::Instance);
}
#[tokio::test]
async fn detect_endpoint_derives_openai_gateway_as_multiplexer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "model-a"}, {"id": "model-b"}]
})))
.mount(&server)
.await;
let result = detect_endpoint(&reqwest::Client::new(), &server.uri(), None)
.await
.unwrap();
assert_eq!(result.kind, BackendKind::Openai);
assert_eq!(result.models, vec!["model-a", "model-b"]);
assert_eq!(result.serving, Serving::Multiplexer);
}
#[tokio::test]
async fn detect_endpoint_ignores_success_with_the_wrong_protocol_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "ok"
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "real-model"}]
})))
.mount(&server)
.await;
let result = detect_endpoint(&reqwest::Client::new(), &server.uri(), None)
.await
.unwrap();
assert_eq!(result.kind, BackendKind::Openai);
assert_eq!(result.models, vec!["real-model"]);
}
#[tokio::test]
async fn detect_endpoint_reports_authentication_required_without_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let error = detect_endpoint(&reqwest::Client::new(), &server.uri(), None)
.await
.unwrap_err()
.to_string();
assert!(error.contains("authentication required"), "{error}");
assert!(error.contains("401"), "{error}");
assert!(error.contains("bearer token"), "{error}");
assert!(error.contains(&server.uri()), "{error}");
}
#[tokio::test]
async fn detect_endpoint_reports_openai_auth_when_ollama_lists_nothing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": []
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let error = detect_endpoint(&reqwest::Client::new(), &server.uri(), None)
.await
.unwrap_err()
.to_string();
assert!(error.contains("authentication required"), "{error}");
}
#[tokio::test]
async fn detect_endpoint_never_sends_the_openai_token_to_ollama() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.and(header("authorization", "Bearer secret-token"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"models": [{"name": "ollama-model"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer secret-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "openai-model"}]
})))
.mount(&server)
.await;
let result = detect_endpoint(&reqwest::Client::new(), &server.uri(), Some("secret-token"))
.await
.unwrap();
assert_eq!(result.kind, BackendKind::Ollama);
server.verify().await;
}
#[tokio::test]
async fn detect_endpoint_reports_authentication_rejected_with_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer wrong-token"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let error = detect_endpoint(&reqwest::Client::new(), &server.uri(), Some("wrong-token"))
.await
.unwrap_err()
.to_string();
assert!(error.contains("authentication rejected"), "{error}");
assert!(error.contains("403"), "{error}");
assert!(error.contains("check the bearer token"), "{error}");
}
#[tokio::test]
async fn detect_endpoint_reports_unsupported_http_service() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let error = detect_endpoint(&reqwest::Client::new(), &server.uri(), None)
.await
.unwrap_err()
.to_string();
assert!(error.contains("unsupported inference endpoint"), "{error}");
assert!(error.contains("/api/tags"), "{error}");
assert!(error.contains("/v1/models"), "{error}");
assert!(error.contains("HTTP 404"), "{error}");
}
#[tokio::test]
async fn detect_endpoint_reports_unreachable_when_probes_time_out() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(250))
.set_body_json(serde_json::json!({"models": []})),
)
.mount(&server)
.await;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(20))
.build()
.unwrap();
let error = detect_endpoint(&client, &server.uri(), None)
.await
.unwrap_err()
.to_string();
assert!(error.contains("unreachable inference endpoint"), "{error}");
assert!(error.contains(&server.uri()), "{error}");
}
#[test]
fn serving_and_openai_window_via_the_trait() {
assert_eq!(OpenAiApi.serving(1), Serving::Instance);
assert_eq!(OpenAiApi.serving(3), Serving::Multiplexer);
assert_eq!(OllamaApi.serving(1), Serving::Multiplexer);
assert_eq!(EmbeddedApi.serving(1), Serving::Instance);
let json = serde_json::json!({
"data": [{ "id": "ornith", "max_model_len": 262144u64 }]
});
assert_eq!(parse_openai_models_window(&json, "ornith"), Some(262144));
assert_eq!(
parse_openai_models_window(&json, "other"),
Some(262144),
"single-instance fallback"
);
assert_eq!(
parse_openai_models_window(&serde_json::json!({"data":[{"id":"m"}]}), "m"),
None
);
}
#[test]
fn instance_adopts_the_served_model_unconditionally() {
let b = openai_backend(Some("configured-model"), None);
let a = adopt(&b, &served(&["ornith-1.0-35b"]), None);
assert_eq!(a.serving, Serving::Instance, "derived: one served id");
assert_eq!(a.model.as_deref(), Some("ornith-1.0-35b"));
assert!(a.requested_ignored, "config disagreed and was overridden");
let agree = adopt(&openai_backend(Some("m"), None), &served(&["m"]), None);
assert!(!agree.requested_ignored);
}
#[test]
fn instance_ignores_a_session_request_too() {
let b = openai_backend(None, Some(Serving::Instance));
let a = adopt(&b, &served(&["real"]), Some("wish"));
assert_eq!(a.model.as_deref(), Some("real"));
assert!(a.requested_ignored);
}
#[test]
fn multiplexer_precedence_requested_then_declared_then_served() {
let b = BackendConfig {
name: "o".into(),
endpoint: "http://h:11434".into(),
model: Some("declared".into()),
kind: Some(BackendKind::Ollama),
..Default::default()
};
let s = served(&["asked", "first", "second"]);
assert_eq!(
adopt(&b, &s, Some("asked")).model.as_deref(),
Some("asked"),
"a served session request wins on a multiplexer"
);
assert_eq!(adopt(&b, &s, None).model.as_deref(), Some("declared"));
let bare = BackendConfig {
model: None,
..b.clone()
};
assert_eq!(adopt(&bare, &s, None).model.as_deref(), Some("asked"));
assert!(!adopt(&b, &s, Some("asked")).requested_ignored);
}
#[test]
fn openai_gateway_with_many_models_is_a_multiplexer() {
let b = openai_backend(None, None);
let a = adopt(&b, &served(&["a", "b", "c"]), Some("b"));
assert_eq!(a.serving, Serving::Multiplexer);
assert_eq!(a.model.as_deref(), Some("b"));
}
#[test]
fn declared_serving_beats_derivation() {
let b = openai_backend(None, Some(Serving::Instance));
let a = adopt(&b, &served(&["x", "y"]), None);
assert_eq!(a.serving, Serving::Instance);
assert_eq!(a.model.as_deref(), Some("x"));
}
#[test]
fn multiplexer_drops_an_unserved_requested_model_fail_soft() {
let b = BackendConfig {
name: "o".into(),
endpoint: "http://h:11434".into(),
model: Some("declared".into()),
kind: Some(BackendKind::Ollama),
..Default::default()
};
let s = served(&["declared", "other"]);
let a = adopt(&b, &s, Some("quen2.5-coder:7b"));
assert_eq!(a.model.as_deref(), Some("declared"));
assert!(a.requested_unavailable);
let ok = adopt(&b, &s, Some("other"));
assert_eq!(ok.model.as_deref(), Some("other"));
assert!(!ok.requested_unavailable);
let trust = adopt(&b, &served(&[]), Some("anything"));
assert_eq!(trust.model.as_deref(), Some("anything"));
assert!(!trust.requested_unavailable);
}
#[test]
fn empty_probe_falls_back_without_flagging() {
let b = openai_backend(Some("hint"), Some(Serving::Instance));
let a = adopt(&b, &served(&[]), None);
assert_eq!(a.model.as_deref(), Some("hint"));
assert!(!a.requested_ignored);
let bare = openai_backend(None, Some(Serving::Instance));
assert_eq!(adopt(&bare, &served(&[]), None).model, None);
}
#[test]
fn responses_only_error_recognizes_openai_wording() {
assert!(is_responses_only_error(
r#"{"error":{"message":"This model is only supported in v1/responses","code":"unsupported_api"}}"#
));
assert!(!is_responses_only_error(
r#"{"error":{"message":"model not found"}}"#
));
assert!(!is_responses_only_error("HTTP 404"));
}
#[tokio::test]
async fn detect_openai_api_keeps_chat_when_completions_succeed() {
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": [{"message": {"content": "ok"}}]
})))
.mount(&server)
.await;
let api = detect_openai_api(&reqwest::Client::new(), &server.uri(), "m", None)
.await
.unwrap();
assert_eq!(api, OpenAiApiSurface::ChatCompletions);
}
#[tokio::test]
async fn detect_openai_api_selects_responses_on_responses_only_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"error": {
"message": "This model is only supported in v1/responses",
"code": "unsupported_api"
}
})))
.mount(&server)
.await;
let api = detect_openai_api(&reqwest::Client::new(), &server.uri(), "gpt-5-codex", None)
.await
.unwrap();
assert_eq!(api, OpenAiApiSurface::Responses);
}
#[tokio::test]
async fn detect_openai_api_falls_through_to_responses_on_bare_chat_404() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"output": []
})))
.mount(&server)
.await;
let api = detect_openai_api(&reqwest::Client::new(), &server.uri(), "m", None)
.await
.unwrap();
assert_eq!(api, OpenAiApiSurface::Responses);
}
}