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:
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user