language_model: Include hosts in transport errors (#62990)

Language model transport errors currently retain only a provider display
name after an HTTP request fails. Downstream clients therefore cannot
tell users which configured endpoint was unreachable without parsing the
source error or hard-coding provider names.

Make the request hostname a required part of
`LanguageModelCompletionError::HttpSend` and preserve it through the
Anthropic, OpenAI-compatible, OpenRouter, Vercel AI Gateway, and Zed
Cloud transports. Each transport captures the parsed hostname from the
request URI before sending it, using the configured base URL as the
defensive fallback. This supports production, development, and custom
endpoints without string matching.

Tests exercise transport failures through the real request paths and
verify hostnames for Anthropic, ChatGPT Subscription, generic
OpenAI-compatible providers, and Zed Cloud.

Testing:

- `cargo fmt --check`
- `cargo nextest run -p anthropic -p open_ai -p open_router -p
language_models_cloud -p language_models` — 209 tests passed
- `cargo check -p agent -p agent_ui --all-targets`
- `git diff --check`
- `./script/clippy -p language_model_core -p anthropic -p open_ai -p
open_router -p language_models_cloud -p language_models` — clippy
passed; the script subsequently failed in the repository-wide `cargo
shear --deny-warnings` step on 203 pre-existing target and doctest
configuration warnings

Release Notes:

- Improved language model connection errors to identify the unreachable
host.
This commit is contained in:
Conrad Irwin authored and GitHub committed 2026-08-21 14:53:09 +00:00
1 parent be775dbac2
commit a637a0a25d
9 files changed
+111 -18

No files matched your search

+34 -4
View File
@@ -346,10 +346,11 @@ pub async fn list_models(
.body(AsyncBody::default())
.map_err(AnthropicError::BuildRequestBody)?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(request)
.await
.map_err(AnthropicError::HttpSend)?;
.map_err(|error| AnthropicError::HttpSend { host, error })?;
if !response.status().is_success() {
let rate_limits = RateLimitInfo::from_headers(response.headers());
@@ -436,10 +437,11 @@ async fn send_request(
.body(AsyncBody::from(serialized_request))
.map_err(AnthropicError::BuildRequestBody)?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let response = client
.send(request)
.await
.map_err(AnthropicError::HttpSend)?;
.map_err(|error| AnthropicError::HttpSend { host, error })?;
let rate_limits = RateLimitInfo::from_headers(response.headers());
@@ -1079,7 +1081,7 @@ pub enum AnthropicError {
BuildRequestBody(http::Error),
/// Failed to send the HTTP request
HttpSend(anyhow::Error),
HttpSend { host: String, error: anyhow::Error },
/// Failed to deserialize the response from JSON
DeserializeResponse(serde_json::Error),
@@ -1198,7 +1200,11 @@ pub fn completion_error_from_anthropic(
match error {
AnthropicError::SerializeRequest(error) => Error::SerializeRequest { provider, error },
AnthropicError::BuildRequestBody(error) => Error::BuildRequestBody { provider, error },
AnthropicError::HttpSend(error) => Error::HttpSend { provider, error },
AnthropicError::HttpSend { host, error } => Error::HttpSend {
provider,
host,
error,
},
AnthropicError::DeserializeResponse(error) => {
Error::DeserializeResponse { provider, error }
}
@@ -1310,6 +1316,30 @@ mod tests {
));
}
#[test]
fn list_models_preserves_anthropic_http_send_hostname() {
let client =
FakeHttpClient::create(|_| async move { Err(anyhow::anyhow!("DNS lookup failed")) });
let error = futures::executor::block_on(list_models(
client.as_ref(),
ANTHROPIC_API_URL,
"test-key",
&CustomHeaders::default(),
))
.expect_err("request should fail");
let completion_error: language_model_core::LanguageModelCompletionError = error.into();
assert!(matches!(
completion_error,
language_model_core::LanguageModelCompletionError::HttpSend {
host,
error,
..
} if host == "api.anthropic.com" && error.to_string() == "DNS lookup failed"
));
}
#[test]
fn list_models_maps_anthropic_billing_errors_to_payment_required() {
let client = FakeHttpClient::create(|_| async move {
+6 -3
View File
@@ -87,10 +87,11 @@ pub async fn create_batch(
.body(AsyncBody::from(serialized_request))
.map_err(AnthropicError::BuildRequestBody)?;
let host = http_request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(http_request)
.await
.map_err(AnthropicError::HttpSend)?;
.map_err(|error| AnthropicError::HttpSend { host, error })?;
let rate_limits = RateLimitInfo::from_headers(response.headers());
@@ -126,10 +127,11 @@ pub async fn retrieve_batch(
.body(AsyncBody::default())
.map_err(AnthropicError::BuildRequestBody)?;
let host = http_request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(http_request)
.await
.map_err(AnthropicError::HttpSend)?;
.map_err(|error| AnthropicError::HttpSend { host, error })?;
let rate_limits = RateLimitInfo::from_headers(response.headers());
@@ -165,10 +167,11 @@ pub async fn retrieve_batch_results(
.body(AsyncBody::default())
.map_err(AnthropicError::BuildRequestBody)?;
let host = http_request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(http_request)
.await
.map_err(AnthropicError::HttpSend)?;
.map_err(|error| AnthropicError::HttpSend { host, error })?;
let rate_limits = RateLimitInfo::from_headers(response.headers());
@@ -183,9 +183,10 @@ pub enum LanguageModelCompletionError {
#[source]
error: http::Error,
},
#[error("error sending HTTP request to {provider} API")]
#[error("error sending HTTP request to {host} for {provider}")]
HttpSend {
provider: LanguageModelProviderName,
host: String,
#[source]
error: anyhow::Error,
},
@@ -520,12 +520,14 @@ async fn list_models(
provider: PROVIDER_NAME,
error,
})?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response =
client
.send(request)
.await
.map_err(|error| LanguageModelCompletionError::HttpSend {
provider: PROVIDER_NAME,
host,
error,
})?;
@@ -170,6 +170,7 @@ impl<TP: CloudLlmTokenProvider> CloudLanguageModel<TP> {
let url = http_client
.build_zed_llm_url(path, &[])
.map_err(LanguageModelCompletionError::Other)?;
let host = url.host_str().unwrap_or(url.as_str()).to_owned();
let body = serde_json::to_string(&body).map_err(|error| {
LanguageModelCompletionError::SerializeRequest {
provider: PROVIDER_NAME,
@@ -196,6 +197,7 @@ impl<TP: CloudLlmTokenProvider> CloudLanguageModel<TP> {
.await
.map_err(|error| LanguageModelCompletionError::HttpSend {
provider: PROVIDER_NAME,
host,
error,
})?;
@@ -1683,6 +1685,27 @@ mod tests {
}
}
#[gpui::test]
async fn cloud_transport_errors_include_hostname(cx: &mut gpui::TestAppContext) {
let http_client =
FakeHttpClient::create(|_| async move { Err(anyhow::anyhow!("DNS lookup failed")) });
let model = cloud_test_model(http_client);
let error = model
.compact(compact_test_request(), &cx.to_async())
.await
.expect_err("request should fail");
assert!(matches!(
error,
LanguageModelCompletionError::HttpSend {
host,
error,
..
} if host == "test.example" && error.to_string() == "DNS lookup failed"
));
}
#[test]
fn test_response_stream_error_maps_to_structured_variant() {
// Read/deserialize failures mid-stream must keep their structured
@@ -129,7 +129,8 @@ fn transport_preserves_typed_send_and_deserialization_errors() {
.expect_err("send error");
assert!(matches!(
error,
RequestError::HttpSend { provider, .. } if provider == "Compatible Provider"
RequestError::HttpSend { provider, host, .. }
if provider == "Compatible Provider" && host == "example.com"
));
let client = FakeHttpClient::create(|_| async move {
+12 -2
View File
@@ -803,9 +803,10 @@ pub enum RequestError {
#[source]
error: http_client::http::Error,
},
#[error("error sending HTTP request to {provider}'s API")]
#[error("error sending HTTP request to {host} for {provider}")]
HttpSend {
provider: String,
host: String,
#[source]
error: anyhow::Error,
},
@@ -877,11 +878,13 @@ where
RequestBody: Serialize + ?Sized,
{
let request = chat_completion_request(provider_name, api_url, api_key, extra_headers, request)?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(request)
.await
.map_err(|error| RequestError::HttpSend {
provider: provider_name.to_string(),
host,
error,
})?;
if !response.status().is_success() {
@@ -945,11 +948,13 @@ where
RequestBody: Serialize + ?Sized,
{
let request = chat_completion_request(provider_name, api_url, api_key, extra_headers, request)?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(request)
.await
.map_err(|error| RequestError::HttpSend {
provider: provider_name.to_string(),
host,
error,
})?;
if !response.status().is_success() {
@@ -1185,8 +1190,13 @@ impl From<RequestError> for language_model_core::LanguageModelCompletionError {
provider: provider.into(),
error,
},
RequestError::HttpSend { provider, error } => Self::HttpSend {
RequestError::HttpSend {
provider,
host,
error,
} => Self::HttpSend {
provider: provider.into(),
host,
error,
},
RequestError::ReadResponse { provider, error } => Self::ApiReadResponseError {
+16 -2
View File
@@ -743,11 +743,13 @@ pub async fn compact_response(
))
.map_err(|error| RequestError::Other(error.into()))?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(request)
.await
.map_err(|error| RequestError::HttpSend {
provider: provider_name.to_owned(),
host,
error,
})?;
let mut body = String::new();
@@ -790,11 +792,13 @@ pub async fn stream_response(
))
.map_err(|e| RequestError::Other(e.into()))?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(request)
.await
.map_err(|error| RequestError::HttpSend {
provider: provider_name.to_owned(),
host,
error,
})?;
if response.status().is_success() {
@@ -1142,8 +1146,13 @@ mod tests {
};
match error {
language_model_core::LanguageModelCompletionError::HttpSend { provider, error } => {
language_model_core::LanguageModelCompletionError::HttpSend {
provider,
host,
error,
} => {
assert_eq!(provider.0.as_ref(), "ChatGPT Subscription");
assert_eq!(host, "chatgpt.com");
assert_eq!(error.to_string(), "DNS lookup failed");
}
error => panic!("expected an HTTP send error, got {error:?}"),
@@ -1169,8 +1178,13 @@ mod tests {
};
match error {
language_model_core::LanguageModelCompletionError::HttpSend { provider, error } => {
language_model_core::LanguageModelCompletionError::HttpSend {
provider,
host,
error,
} => {
assert_eq!(provider.0.as_ref(), "ChatGPT Subscription");
assert_eq!(host, "chatgpt.com");
assert_eq!(error.to_string(), "DNS lookup failed");
}
error => panic!("expected an HTTP send error, got {error:?}"),
+14 -5
View File
@@ -560,10 +560,11 @@ pub async fn list_models(
.extra_headers(extra_headers)
.body(AsyncBody::default())
.map_err(OpenRouterError::BuildRequestBody)?;
let host = request.uri().host().unwrap_or(api_url).to_owned();
let mut response = client
.send(request)
.await
.map_err(OpenRouterError::HttpSend)?;
.map_err(|error| OpenRouterError::HttpSend { host, error })?;
let mut body = String::new();
response
@@ -656,7 +657,7 @@ pub enum OpenRouterError {
BuildRequestBody(http::Error),
/// Failed to send the HTTP request
HttpSend(anyhow::Error),
HttpSend { host: String, error: anyhow::Error },
/// Failed to deserialize the response from JSON
DeserializeResponse(serde_json::Error),
@@ -796,7 +797,11 @@ impl From<OpenRouterError> for language_model_core::LanguageModelCompletionError
let provider = language_model_core::LanguageModelProviderName::new("OpenRouter");
match error {
OpenRouterError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error },
OpenRouterError::HttpSend(error) => Self::HttpSend { provider, error },
OpenRouterError::HttpSend { host, error } => Self::HttpSend {
provider,
host,
error,
},
OpenRouterError::DeserializeResponse(error) => {
Self::DeserializeResponse { provider, error }
}
@@ -938,14 +943,18 @@ mod tests {
fn shared_transport_errors_retain_language_model_classification() {
let error = OpenRouterError::ChatCompletion(open_ai::RequestError::HttpSend {
provider: "OpenRouter".to_string(),
host: "openrouter.ai".to_string(),
error: anyhow!("network unavailable"),
});
let error = language_model_core::LanguageModelCompletionError::from(error);
assert!(matches!(
error,
language_model_core::LanguageModelCompletionError::HttpSend { provider, .. }
if provider.0 == "OpenRouter"
language_model_core::LanguageModelCompletionError::HttpSend {
provider,
host,
..
} if provider.0 == "OpenRouter" && host == "openrouter.ai"
));
}
}