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:
1 parent
be775dbac2
commit
a637a0a25d
9 files changed
+111
-18
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user