Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 42 additions & 14 deletions crates/tinyinference-llm/src/catalog/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,39 @@ use std::fmt;

/// A catalog request or response failed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelsError(String);
pub struct ListModelsError {
message: String,
http_status: Option<u16>,
}

impl ListModelsError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
Self {
message: message.into(),
http_status: None,
}
}

fn from_http_response(message: impl Into<String>, status: u16) -> Self {
Self {
message: message.into(),
http_status: Some(status),
}
}

/// Returns the HTTP status when the error came from a non-success response.
///
/// Request failures, malformed responses, and provider error payloads do
/// not carry a status, even if their message or body mentions one.
#[must_use]
pub fn http_status(&self) -> Option<u16> {
self.http_status
}
}

impl fmt::Display for ListModelsError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
formatter.write_str(&self.message)
}
}

Expand Down Expand Up @@ -56,14 +78,17 @@ pub async fn list_models(
let body = response.text().await.unwrap_or_default();
let snippet = snippet(&body);
if status.as_u16() == 404 {
return Err(ListModelsError::new(format!(
"provider returned 404: {snippet} — the configured base URL does not expose a `/models` endpoint; check the provider's base URL (it usually ends in `/v1`)"
)));
return Err(ListModelsError::from_http_response(
format!(
"provider returned 404: {snippet} — the configured base URL does not expose a `/models` endpoint; check the provider's base URL (it usually ends in `/v1`)"
),
status.as_u16(),
));
}
return Err(ListModelsError::new(format!(
"provider returned {}: {snippet}",
status.as_u16()
)));
return Err(ListModelsError::from_http_response(
format!("provider returned {}: {snippet}", status.as_u16()),
status.as_u16(),
));
}

let raw_body = response.text().await.map_err(|error| {
Expand Down Expand Up @@ -127,11 +152,14 @@ pub async fn validate_openrouter_key(
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(ListModelsError::new(format!(
"OpenRouter key validation returned {}: {}",
return Err(ListModelsError::from_http_response(
format!(
"OpenRouter key validation returned {}: {}",
status.as_u16(),
snippet(&body)
),
status.as_u16(),
snippet(&body)
)));
));
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body)
&& let Some(error) = value.get("error")
Expand Down
70 changes: 70 additions & 0 deletions crates/tinyinference-llm/src/catalog/fetch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,61 @@ async fn list_models_preserves_404_hint_and_rejects_success_error_payload() {
task.abort();
}

#[tokio::test]
async fn list_models_error_exposes_only_the_actual_http_status() {
let client = reqwest::Client::new();

for (status, expected) in [
(StatusCode::UNAUTHORIZED, 401),
(StatusCode::FORBIDDEN, 403),
(StatusCode::NOT_FOUND, 404),
] {
let (base, task) = serve(status, "response mentions 401").await;
let error = list_models(
&client,
crate::model::discover::DiscoveryRequest::new(format!("{base}/v1"), "model"),
)
.await
.expect_err("non-success HTTP status should fail");

assert_eq!(error.http_status(), Some(expected));
task.abort();
}

let (base, task) = serve(StatusCode::OK, r#"{"error":{"message":"401 rejected"}}"#).await;
let error = list_models(
&client,
crate::model::discover::DiscoveryRequest::new(format!("{base}/v1"), "model"),
)
.await
.expect_err("error payload should fail");
assert_eq!(error.http_status(), None);
task.abort();

let (base, task) = serve(StatusCode::OK, "not JSON").await;
let error = list_models(
&client,
crate::model::discover::DiscoveryRequest::new(format!("{base}/v1"), "model"),
)
.await
.expect_err("malformed JSON should fail");
assert_eq!(error.http_status(), None);
task.abort();

let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind closed fixture");
let address = listener.local_addr().expect("closed fixture address");
drop(listener);
let error = list_models(
&client,
crate::model::discover::DiscoveryRequest::new(format!("http://{address}/v1"), "model"),
)
.await
.expect_err("closed local port should fail");
assert_eq!(error.http_status(), None);
}

#[tokio::test]
async fn malformed_success_body_has_a_bounded_sanitized_diagnostic() {
let token = "sk-synthetic-secret-12345678901234567890";
Expand Down Expand Up @@ -255,6 +310,21 @@ async fn openrouter_validation_sends_bearer_and_rejects_error_payload() {
task.abort();
}

#[tokio::test]
async fn openrouter_validation_error_exposes_actual_http_status() {
let (base, task) = serve(StatusCode::FORBIDDEN, "response mentions 401").await;
let error = validate_openrouter_key(&reqwest::Client::new(), &base, "fixture-key")
.await
.expect_err("non-success HTTP status should fail");

assert_eq!(error.http_status(), Some(403));
assert_eq!(
error.to_string(),
"OpenRouter key validation returned 403: response mentions 401"
);
task.abort();
}

#[tokio::test]
async fn openrouter_validation_rejects_blank_key_without_sending_request() {
let requests = Arc::new(AtomicUsize::new(0));
Expand Down
Loading