fix(providers): fall back to configured models when models endpoint fetch fails (#7530)

Signed-off-by: Pete Gonzalez <4673363+octogonz@users.noreply.github.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Pete Gonzalez <octogonz@users.noreply.github.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Pete Gonzalez
2026-03-26 12:49:37 -07:00
committed by GitHub
parent 4a02379731
commit 27e197e467
4 changed files with 139 additions and 43 deletions
+59 -20
View File
@@ -55,6 +55,7 @@ pub struct AnthropicProvider {
model: ModelConfig, model: ModelConfig,
supports_streaming: bool, supports_streaming: bool,
name: String, name: String,
custom_models: Option<Vec<String>>,
} }
impl AnthropicProvider { impl AnthropicProvider {
@@ -80,6 +81,7 @@ impl AnthropicProvider {
model, model,
supports_streaming: true, supports_streaming: true,
name: ANTHROPIC_PROVIDER_NAME.to_string(), name: ANTHROPIC_PROVIDER_NAME.to_string(),
custom_models: None,
}) })
} }
@@ -119,11 +121,18 @@ impl AnthropicProvider {
)); ));
} }
let custom_models = if !config.models.is_empty() {
Some(config.models.iter().map(|m| m.name.clone()).collect())
} else {
None
};
Ok(Self { Ok(Self {
api_client, api_client,
model, model,
supports_streaming, supports_streaming,
name: config.name.clone(), name: config.name.clone(),
custom_models,
}) })
} }
@@ -139,6 +148,42 @@ impl AnthropicProvider {
headers headers
} }
async fn fetch_models_from_api(&self) -> Result<Vec<String>, ProviderError> {
let response = self.api_client.request(None, "v1/models").api_get().await?;
if response.status == StatusCode::NOT_FOUND {
let msg = response
.payload
.as_ref()
.and_then(|p| p.get("error").and_then(|e| e.get("message")))
.and_then(|m| m.as_str())
.unwrap_or("models endpoint not found")
.to_string();
return Err(ProviderError::EndpointNotFound(msg));
}
if response.status != StatusCode::OK {
return Err(map_http_error_to_provider_error(
response.status,
response.payload,
));
}
let json = response.payload.unwrap_or_default();
let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::RequestFailed(
"Missing 'data' array in Anthropic models response".to_string(),
)
})?;
let mut models: Vec<String> = arr
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
models.sort();
Ok(models)
}
} }
impl ProviderDef for AnthropicProvider { impl ProviderDef for AnthropicProvider {
@@ -194,28 +239,22 @@ impl Provider for AnthropicProvider {
} }
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> { async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self.api_client.request(None, "v1/models").api_get().await?; if let Some(custom_models) = &self.custom_models {
match self.fetch_models_from_api().await {
if response.status != StatusCode::OK { Ok(models) => return Ok(models),
return Err(map_http_error_to_provider_error( Err(e) if e.is_endpoint_not_found() => {
response.status, tracing::debug!(
response.payload, "Models endpoint not implemented for provider '{}' ({}), using predefined list",
)); self.name,
e
);
return Ok(custom_models.clone());
}
Err(e) => return Err(e),
}
} }
let json = response.payload.unwrap_or_default(); self.fetch_models_from_api().await
let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::RequestFailed(
"Missing 'data' array in Anthropic models response".to_string(),
)
})?;
let mut models: Vec<String> = arr
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
models.sort();
Ok(models)
} }
async fn stream( async fn stream(
+8
View File
@@ -34,6 +34,9 @@ pub enum ProviderError {
#[error("Unsupported operation: {0}")] #[error("Unsupported operation: {0}")]
NotImplemented(String), NotImplemented(String),
#[error("Endpoint not found (404): {0}")]
EndpointNotFound(String),
#[error("Credits exhausted: {details}")] #[error("Credits exhausted: {details}")]
CreditsExhausted { CreditsExhausted {
details: String, details: String,
@@ -53,9 +56,14 @@ impl ProviderError {
ProviderError::ExecutionError(_) => "execution", ProviderError::ExecutionError(_) => "execution",
ProviderError::UsageError(_) => "usage", ProviderError::UsageError(_) => "usage",
ProviderError::NotImplemented(_) => "not_implemented", ProviderError::NotImplemented(_) => "not_implemented",
ProviderError::EndpointNotFound(_) => "endpoint_not_found",
ProviderError::CreditsExhausted { .. } => "credits_exhausted", ProviderError::CreditsExhausted { .. } => "credits_exhausted",
} }
} }
pub fn is_endpoint_not_found(&self) -> bool {
matches!(self, ProviderError::EndpointNotFound(_))
}
} }
fn is_network_error(err: &reqwest::Error) -> bool { fn is_network_error(err: &reqwest::Error) -> bool {
+59 -23
View File
@@ -65,6 +65,7 @@ pub struct OpenAiProvider {
custom_headers: Option<HashMap<String, String>>, custom_headers: Option<HashMap<String, String>>,
supports_streaming: bool, supports_streaming: bool,
name: String, name: String,
custom_models: Option<Vec<String>>,
skip_canonical_filtering: bool, skip_canonical_filtering: bool,
} }
@@ -127,6 +128,7 @@ impl OpenAiProvider {
custom_headers, custom_headers,
supports_streaming: true, supports_streaming: true,
name: OPEN_AI_PROVIDER_NAME.to_string(), name: OPEN_AI_PROVIDER_NAME.to_string(),
custom_models: None,
skip_canonical_filtering: false, skip_canonical_filtering: false,
}) })
} }
@@ -142,6 +144,7 @@ impl OpenAiProvider {
custom_headers: None, custom_headers: None,
supports_streaming: true, supports_streaming: true,
name: OPEN_AI_PROVIDER_NAME.to_string(), name: OPEN_AI_PROVIDER_NAME.to_string(),
custom_models: None,
skip_canonical_filtering: false, skip_canonical_filtering: false,
} }
} }
@@ -212,6 +215,12 @@ impl OpenAiProvider {
api_client = api_client.with_headers(header_map)?; api_client = api_client.with_headers(header_map)?;
} }
let custom_models = if !config.models.is_empty() {
Some(config.models.iter().map(|m| m.name.clone()).collect())
} else {
None
};
Ok(Self { Ok(Self {
api_client, api_client,
base_path, base_path,
@@ -221,6 +230,7 @@ impl OpenAiProvider {
custom_headers: config.headers, custom_headers: config.headers,
supports_streaming: config.supports_streaming.unwrap_or(true), supports_streaming: config.supports_streaming.unwrap_or(true),
name: config.name.clone(), name: config.name.clone(),
custom_models,
skip_canonical_filtering: config.skip_canonical_filtering, skip_canonical_filtering: config.skip_canonical_filtering,
}) })
} }
@@ -314,6 +324,40 @@ impl OpenAiProvider {
fallback.to_string() fallback.to_string()
} }
} }
async fn fetch_models_from_api(&self) -> Result<Vec<String>, ProviderError> {
let models_path =
Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH);
let response = self
.api_client
.request(None, &models_path)
.response_get()
.await?;
if response.status() == StatusCode::NOT_FOUND {
let body = response.text().await.unwrap_or_default();
return Err(ProviderError::EndpointNotFound(body));
}
let json = handle_response_openai_compat(response).await?;
if let Some(err_obj) = json.get("error") {
let msg = err_obj
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ProviderError::Authentication(msg.to_string()));
}
let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::UsageError("Missing data field in JSON response".into())
})?;
let mut models: Vec<String> = data
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
models.sort();
Ok(models)
}
} }
impl ProviderDef for OpenAiProvider { impl ProviderDef for OpenAiProvider {
@@ -384,31 +428,22 @@ impl Provider for OpenAiProvider {
} }
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> { async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let models_path = if let Some(custom_models) = &self.custom_models {
Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); match self.fetch_models_from_api().await {
let response = self Ok(models) => return Ok(models),
.api_client Err(e) if e.is_endpoint_not_found() => {
.request(None, &models_path) tracing::debug!(
.response_get() "Models endpoint not implemented for provider '{}' ({}), using predefined list",
.await?; self.name,
let json = handle_response_openai_compat(response).await?; e
if let Some(err_obj) = json.get("error") { );
let msg = err_obj return Ok(custom_models.clone());
.get("message") }
.and_then(|v| v.as_str()) Err(e) => return Err(e),
.unwrap_or("unknown error"); }
return Err(ProviderError::Authentication(msg.to_string()));
} }
let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { self.fetch_models_from_api().await
ProviderError::UsageError("Missing data field in JSON response".into())
})?;
let mut models: Vec<String> = data
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.collect();
models.sort();
Ok(models)
} }
fn supports_embeddings(&self) -> bool { fn supports_embeddings(&self) -> bool {
@@ -635,6 +670,7 @@ mod tests {
custom_headers: None, custom_headers: None,
supports_streaming: true, supports_streaming: true,
name: name.to_string(), name: name.to_string(),
custom_models: None,
skip_canonical_filtering: false, skip_canonical_filtering: false,
} }
} }
@@ -299,6 +299,18 @@ mod tests {
"ServerError" "ServerError"
; "500 server error" ; "500 server error"
)] )]
#[test_case(
StatusCode::NOT_FOUND,
None,
"RequestFailed"
; "404 not found"
)]
#[test_case(
StatusCode::NOT_FOUND,
Some(json!({"error": {"message": "model not available"}})),
"RequestFailed"
; "404 with error payload"
)]
fn http_status_maps_to_expected_error( fn http_status_maps_to_expected_error(
status: StatusCode, status: StatusCode,
payload: Option<Value>, payload: Option<Value>,
@@ -312,6 +324,7 @@ mod tests {
"Authentication" => "auth", "Authentication" => "auth",
"ContextLengthExceeded" => "context_length", "ContextLengthExceeded" => "context_length",
"ServerError" => "server", "ServerError" => "server",
"RequestFailed" => "request",
other => panic!("Unknown variant: {other}"), other => panic!("Unknown variant: {other}"),
}; };
assert_eq!( assert_eq!(