fix(inference): local model reliability — /api/tags fallback + real Ollama model ids (#5219)

This commit is contained in:
Mega Mind
2026-07-27 21:43:26 +05:30
committed by GitHub
parent 06394789f4
commit cf71f557c4
8 changed files with 469 additions and 25 deletions
+51 -3
View File
@@ -291,11 +291,28 @@ pub(crate) struct LmStudioNativeModelsResponse {
/// Map a normalized `…/v1` base URL to the LM Studio native models endpoint
/// `…/api/v0/models` (a sibling of `/v1`, served at the host root).
pub(crate) fn lm_studio_native_models_url(v1_base_url: &str) -> String {
let root = v1_base_url
format!("{}/api/v0/models", host_root_of(v1_base_url))
}
/// Strip a trailing `/v1` so sibling endpoints served at the host root can be
/// derived from an OpenAI-compatible base URL.
pub(crate) fn host_root_of(v1_base_url: &str) -> &str {
v1_base_url
.trim_end_matches('/')
.trim_end_matches("/v1")
.trim_end_matches('/');
format!("{root}/api/v0/models")
.trim_end_matches('/')
}
/// Ollama-native `GET /api/tags` URL derived from an OpenAI-compatible base.
///
/// Only used as the one-shot 404 fallback in
/// [`LocalAiService::list_lm_studio_models`](crate::openhuman::inference::local::service::LocalAiService):
/// some runtimes are reachable on an OpenAI-shaped base URL but expose only the
/// Ollama listing (e.g. plain Ollama configured with a `/v1` base). Discovery is
/// still chosen by provider type first — this is a recovery path, not a probe
/// order (GH #5055).
pub(crate) fn ollama_tags_fallback_url(v1_base_url: &str) -> String {
format!("{}/api/tags", host_root_of(v1_base_url))
}
/// Resolve the context window LM Studio reports for `model_id` from a native
@@ -336,6 +353,37 @@ mod tests {
);
}
/// GH #5055: the `/api/tags` fallback URL is a sibling of `/v1` at the host
/// root. Appending to the `/v1` base would produce `/v1/api/tags` — the
/// exact malformed request LM Studio logs as `Unexpected endpoint or
/// method` (GH #5053).
#[test]
fn ollama_tags_fallback_url_is_host_rooted_not_v1_suffixed() {
assert_eq!(
ollama_tags_fallback_url("http://localhost:1234/v1"),
"http://localhost:1234/api/tags"
);
assert_eq!(
ollama_tags_fallback_url("http://127.0.0.1:1234/v1/"),
"http://127.0.0.1:1234/api/tags"
);
assert_eq!(
ollama_tags_fallback_url("https://lm.example.com/lmstudio/v1"),
"https://lm.example.com/lmstudio/api/tags"
);
// A host-rooted base (no /v1) is left alone.
assert_eq!(
ollama_tags_fallback_url("http://localhost:11434"),
"http://localhost:11434/api/tags"
);
for url in [
ollama_tags_fallback_url("http://localhost:1234/v1"),
ollama_tags_fallback_url("http://localhost:1234/v1/"),
] {
assert!(!url.contains("/v1/api/tags"), "malformed probe URL: {url}");
}
}
#[test]
fn context_window_prefers_loaded_then_max() {
let resp: LmStudioNativeModelsResponse = serde_json::from_str(
+8
View File
@@ -312,6 +312,14 @@ impl OllamaPullProgress {
pub(crate) struct OllamaTagsResponse {
#[serde(default)]
pub models: Vec<OllamaModelTag>,
/// Set when the server answered with an error envelope rather than a
/// catalog. LM Studio replies to unknown paths with `200 {"error": …}` and
/// no `models` (GH #5053), which is indistinguishable from a real catalog
/// by `models` alone — a fresh Ollama with nothing pulled legitimately
/// returns `{"models":[]}`. Callers must branch on this field, not on
/// emptiness.
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -1,9 +1,10 @@
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::lm_studio::{
apply_lm_studio_auth, lm_studio_base_url, LmStudioChatCompletionRequest,
LmStudioChatCompletionResponse, LmStudioChatMessage, LmStudioModelsResponse,
apply_lm_studio_auth, lm_studio_base_url, ollama_tags_fallback_url,
LmStudioChatCompletionRequest, LmStudioChatCompletionResponse, LmStudioChatMessage,
LmStudioModelsResponse,
};
use crate::openhuman::inference::local::ollama::OllamaModelTag;
use crate::openhuman::inference::local::ollama::{OllamaModelTag, OllamaTagsResponse};
use crate::openhuman::inference::model_ids;
use super::LocalAiService;
@@ -43,11 +44,15 @@ impl LocalAiService {
) -> Result<Vec<OllamaModelTag>, String> {
let base = lm_studio_base_url(config);
let url = format!("{base}/models");
// GH #5055: log the *resolved* discovery URL so a wrong base URL is
// diagnosable from app logs alone, without reproducing against the
// runtime's own request log.
tracing::debug!(
target: "local_ai::lm_studio",
%base,
%url,
"[local_ai:lm_studio] list_models: sending GET"
discovery_url = %url,
api = "openai_v1_models",
"[local_ai:lm_studio] list_models: resolved discovery URL — sending GET"
);
let request = self
@@ -78,6 +83,20 @@ impl LocalAiService {
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] list_models: non-success response"
);
// GH #5055: a 404 on `/v1/models` means this host is reachable but
// does not serve the OpenAI catalog. Try the Ollama-native
// `/api/tags` exactly once before giving up, so a runtime that only
// speaks the Ollama listing still discovers its models. Discovery is
// still selected by provider *type* first (`model_discovery_api`);
// this is a recovery path, never a probe order, and it never runs
// for any status other than 404.
if status == reqwest::StatusCode::NOT_FOUND {
if let Some(models) = self.list_ollama_tags_fallback(config, &base).await {
return Ok(models);
}
}
return Err(format!(
"lm studio models failed with status {}{}",
status,
@@ -120,6 +139,122 @@ impl LocalAiService {
.collect())
}
/// One-shot Ollama-native `/api/tags` fallback for a `/v1/models` 404.
///
/// Returns `Some(models)` only when the fallback actually produced a
/// catalog; every failure returns `None` so the caller surfaces the original
/// `/v1/models` error rather than a confusing second one. Logs at WARN on
/// entry because taking this path means the configured base URL and the
/// provider type disagree — the user should fix the configuration even
/// though discovery recovered (GH #5055).
async fn list_ollama_tags_fallback(
&self,
config: &Config,
base: &str,
) -> Option<Vec<OllamaModelTag>> {
let fallback_url = ollama_tags_fallback_url(base);
tracing::warn!(
target: "local_ai::lm_studio",
%base,
discovery_url = %fallback_url,
api = "ollama_api_tags",
"[local_ai:lm_studio] list_models: /v1/models returned 404 — retrying once against \
the Ollama-native /api/tags. Check the configured base URL: an OpenAI-compatible \
runtime should serve /v1/models."
);
let request = self
.http
.get(&fallback_url)
.timeout(std::time::Duration::from_secs(5));
let response = match apply_lm_studio_auth(request, config).send().await {
Ok(r) => r,
Err(e) => {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %e,
"[local_ai:lm_studio] /api/tags fallback request failed"
);
return None;
}
};
let status = response.status();
if !status.is_success() {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
%status,
"[local_ai:lm_studio] /api/tags fallback returned non-success"
);
return None;
}
let body = match response.text().await {
Ok(b) => b,
Err(e) => {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %e,
"[local_ai:lm_studio] /api/tags fallback body read failed"
);
return None;
}
};
let payload: OllamaTagsResponse = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %e,
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] /api/tags fallback parse failed"
);
return None;
}
};
// Reject on an explicit error envelope, NOT on emptiness. LM Studio
// answers unknown paths with `200 {"error": …}` and no models
// (GH #5053) — that is the case that must fall through to the original
// /v1/models error. A fresh Ollama with nothing pulled yet legitimately
// returns `{"models":[]}`, and treating that as a failure hid a
// reachable runtime behind a 404, so the UI could not offer the
// model-download action.
if let Some(error) = payload
.error
.as_deref()
.map(str::trim)
.filter(|e| !e.is_empty())
{
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %error,
"[local_ai:lm_studio] /api/tags fallback returned an error envelope — not a recovery"
);
return None;
}
if payload.models.is_empty() {
tracing::info!(
target: "local_ai::lm_studio",
url = %fallback_url,
"[local_ai:lm_studio] /api/tags fallback reached a reachable runtime with an empty catalog — recovering as zero models"
);
}
tracing::info!(
target: "local_ai::lm_studio",
url = %fallback_url,
model_count = payload.models.len(),
"[local_ai:lm_studio] recovered model discovery via the Ollama /api/tags fallback"
);
Some(payload.models)
}
pub(in crate::openhuman::inference::local::service) async fn has_lm_studio_model(
&self,
config: &Config,
@@ -1277,3 +1277,187 @@ async fn diagnostics_gates_models_by_context_window() {
std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL");
}
}
// ── GH #5055: one-shot /api/tags fallback on a /v1/models 404 ───────────────
//
// Discovery is still chosen by provider *type* (`model_discovery_api`); these
// cover the recovery path taken when the chosen OpenAI-compatible endpoint
// answers 404, so a runtime that only speaks the Ollama listing is not left
// with an empty catalog.
/// A `/v1/models` 404 falls back to the host-rooted `/api/tags` exactly once
/// and returns that catalog.
#[tokio::test]
async fn v1_models_404_falls_back_to_ollama_api_tags() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new()
.route(
"/v1/models",
get(|| async { (axum::http::StatusCode::NOT_FOUND, "404 page not found") }),
)
.route(
"/api/tags",
get(|| async {
Json(json!({
"models": [
{ "name": "local-model", "size": 42, "modified_at": "2026-01-01T00:00:00Z" }
]
}))
}),
);
let base = spawn_mock(app).await;
let config = lm_studio_config(&base);
let service = LocalAiService::new(&config);
let models = service
.list_lm_studio_models(&config)
.await
.expect("the /api/tags fallback should recover discovery");
assert_eq!(models.len(), 1);
assert_eq!(models[0].name, "local-model");
}
/// When neither endpoint serves a catalog, the caller sees the original
/// `/v1/models` failure — not a second, more confusing error from the fallback.
#[tokio::test]
async fn v1_models_404_reports_the_original_error_when_fallback_also_fails() {
let _guard = crate::openhuman::inference::inference_test_guard();
// The fallback returns a DISTINCT status (503, not 404). Without that,
// Axum's implicit fallback route also answers 404 and the assertion below
// would pass even if the implementation surfaced the /api/tags failure.
let app = Router::new()
.route(
"/v1/models",
get(|| async {
(
axum::http::StatusCode::NOT_FOUND,
"404 page not found: /v1/models",
)
}),
)
.route(
"/api/tags",
get(|| async {
(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"503 fallback unavailable",
)
}),
);
let base = spawn_mock(app).await;
let config = lm_studio_config(&base);
let service = LocalAiService::new(&config);
let err = service
.list_lm_studio_models(&config)
.await
.expect_err("no catalog anywhere must fail");
assert!(
err.contains("404"),
"expected the original /v1/models status, got: {err}"
);
assert!(
!err.contains("503"),
"the fallback failure must not replace the original error, got: {err}"
);
}
/// LM Studio answers unknown paths with `200 {"error": …}` and no models
/// (GH #5053). That ERROR ENVELOPE must not be treated as a recovery,
/// otherwise discovery silently "succeeds" with zero models.
#[tokio::test]
async fn error_envelope_api_tags_fallback_is_not_treated_as_recovery() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new()
.route(
"/v1/models",
get(|| async { (axum::http::StatusCode::NOT_FOUND, "404 page not found") }),
)
.route(
"/api/tags",
get(|| async { Json(json!({ "error": "Unexpected endpoint or method." })) }),
);
let base = spawn_mock(app).await;
let config = lm_studio_config(&base);
let service = LocalAiService::new(&config);
let err = service
.list_lm_studio_models(&config)
.await
.expect_err("an error envelope is not a recovery");
assert!(err.contains("404"), "got: {err}");
}
/// A fresh Ollama with nothing pulled answers `{"models":[]}`. That is a
/// REACHABLE runtime, not a failure: rejecting it hid the server behind the
/// original 404 so the UI could not offer the model-download action.
#[tokio::test]
async fn empty_api_tags_fallback_recovers_as_zero_models() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new()
.route(
"/v1/models",
get(|| async { (axum::http::StatusCode::NOT_FOUND, "404 page not found") }),
)
.route("/api/tags", get(|| async { Json(json!({ "models": [] })) }));
let base = spawn_mock(app).await;
let config = lm_studio_config(&base);
let service = LocalAiService::new(&config);
let models = service
.list_lm_studio_models(&config)
.await
.expect("a reachable runtime with no models is not an error");
assert!(
models.is_empty(),
"expected an empty catalog, got {models:?}"
);
}
/// Any non-404 failure must NOT trigger the fallback: a 500 is a server fault,
/// not a wrong-endpoint signal, and probing a second path would mask it.
#[tokio::test]
async fn non_404_status_does_not_trigger_the_fallback() {
let _guard = crate::openhuman::inference::inference_test_guard();
let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let hits_route = std::sync::Arc::clone(&hits);
let app = Router::new()
.route(
"/v1/models",
get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom") }),
)
.route(
"/api/tags",
get(move || {
let hits = std::sync::Arc::clone(&hits_route);
async move {
hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Json(json!({ "models": [{ "name": "should-not-be-used" }] }))
}
}),
);
let base = spawn_mock(app).await;
let config = lm_studio_config(&base);
let service = LocalAiService::new(&config);
let err = service
.list_lm_studio_models(&config)
.await
.expect_err("a 500 must surface, not fall back");
assert!(err.contains("500"), "got: {err}");
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
0,
"the /api/tags fallback must not run for a non-404 status"
);
}
+34 -5
View File
@@ -19,7 +19,14 @@ pub(crate) const DEFAULT_OLLAMA_EMBED_MODEL: &str = "bge-m3";
/// Chat models allowed in the current local Ollama build.
/// Any resolved chat model ID not listed here is redirected to `MVP_DEFAULT_CHAT_MODEL`.
const MVP_ALLOWED_CHAT_MODELS: &[&str] = &["gemma3:1b-it-qat", "gemma4:e4b-it-q8_0"];
///
/// Every id here must be pullable from the public Ollama library as written —
/// an entry that does not resolve makes the allowlist silently redirect the
/// user back to the default, or leaves them with a model that `ollama pull`
/// cannot fetch (GH #5055). `gemma4:*` was such an entry: there is no `gemma4`
/// namespace on Ollama. The intended model is Gemma 3n, published as
/// `gemma3n:e4b-it-q8_0`.
const MVP_ALLOWED_CHAT_MODELS: &[&str] = &["gemma3:1b-it-qat", "gemma3n:e4b-it-q8_0"];
const MVP_DEFAULT_CHAT_MODEL: &str = "gemma3:1b-it-qat";
/// Vision models allowed in MVP — only disabled (empty string) since the
@@ -233,10 +240,10 @@ mod tests {
}
#[test]
fn chat_model_allows_requested_ollama_gemma4_q8() {
fn chat_model_allows_requested_ollama_gemma3n_q8() {
let mut config = test_config();
config.local_ai.chat_model_id = "gemma4:e4b-it-q8_0".to_string();
assert_eq!(effective_chat_model_id(&config), "gemma4:e4b-it-q8_0");
config.local_ai.chat_model_id = "gemma3n:e4b-it-q8_0".to_string();
assert_eq!(effective_chat_model_id(&config), "gemma3n:e4b-it-q8_0");
}
#[test]
@@ -272,8 +279,30 @@ mod tests {
config.local_ai.chat_model_id = "gemma3:270m-it-qat".to_string();
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
config.local_ai.chat_model_id = "gemma4:e4b".to_string();
// Bare `gemma3n:e4b` is a real Ollama tag but is NOT the allowlisted
// quantization, so it still redirects to the default.
config.local_ai.chat_model_id = "gemma3n:e4b".to_string();
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
// The retired `gemma4:*` namespace does not exist on Ollama at all.
config.local_ai.chat_model_id = "gemma4:e4b-it-q8_0".to_string();
assert_eq!(effective_chat_model_id(&config), MVP_DEFAULT_CHAT_MODEL);
}
/// GH #5055: every allowlisted chat model must be a real, pullable Ollama
/// id. `gemma4:*` shipped for a while and could never be pulled.
#[test]
fn mvp_chat_allowlist_has_no_unpullable_namespaces() {
for model in MVP_ALLOWED_CHAT_MODELS {
assert!(
!model.starts_with("gemma4:"),
"`{model}` is not a real Ollama model — there is no gemma4 namespace"
);
assert!(
model.contains(':'),
"`{model}` must be a fully-qualified `<model>:<tag>` id"
);
}
}
#[test]
+44 -4
View File
@@ -169,11 +169,19 @@ pub fn all_presets() -> Vec<ModelPreset> {
ModelPreset {
tier: ModelTier::Ram16PlusGb,
label: "16 GB+",
description: "Best local quality with Gemma 4 on higher-end devices.",
chat_model_id: "gemma4:e4b",
vision_model_id: "gemma4:e4b",
description: "Best local quality with Gemma 3n on higher-end devices.",
// GH #5055: was `gemma4:e4b`, which does not exist on the Ollama
// library — the tier shipped a model no `ollama pull` could fetch.
// Gemma 3n is the real publication of the "e4b" (effective-4B)
// variant, and `e4b-it-q8_0` (9.5 GB) is what `approx_download_gb`
// below was sized against.
chat_model_id: "gemma3n:e4b-it-q8_0",
vision_model_id: "gemma3n:e4b-it-q8_0",
embedding_model_id: "nomic-embed-text:latest",
quantization: "qat",
// The other tiers ship QAT builds; this one is q8_0. The field is a
// display label (`effective_quantization`) and does not take part in
// resolving the model tag, but it should still match what is pulled.
quantization: "q8_0",
vision_mode: VisionMode::Bundled,
supports_screen_summary: true,
target_ram_gb: 16,
@@ -423,4 +431,36 @@ mod tests {
apply_preset_to_config(&mut config, ModelTier::Ram16PlusGb);
assert_eq!(vision_mode_for_config(&config), VisionMode::Bundled);
}
/// GH #5055: every preset must name a model that actually exists on the
/// Ollama library. The 16 GB+ tier shipped `gemma4:e4b` for chat and vision
/// — there is no `gemma4` namespace, so the tier offered a model that no
/// `ollama pull` could ever fetch. Guard the whole table, not just that row.
#[test]
fn presets_reference_no_unpullable_gemma4_namespace() {
for preset in all_presets() {
for (field, id) in [
("chat", preset.chat_model_id),
("vision", preset.vision_model_id),
("embedding", preset.embedding_model_id),
] {
assert!(
!id.starts_with("gemma4:"),
"preset {:?} {field} model `{id}` is not a real Ollama model \
— there is no gemma4 namespace",
preset.tier
);
}
}
}
/// The 16 GB+ tier's chat model must be the allowlisted Gemma 3n build, so
/// selecting the preset does not immediately get redirected back to the
/// default by `enforce_mvp_chat_allowlist`.
#[test]
fn high_tier_preset_uses_the_allowlisted_gemma3n_build() {
let preset = preset_for_tier(ModelTier::Ram16PlusGb).expect("16 GB+ preset");
assert_eq!(preset.chat_model_id, "gemma3n:e4b-it-q8_0");
assert_eq!(preset.vision_model_id, "gemma3n:e4b-it-q8_0");
}
}
@@ -96,7 +96,7 @@ async fn local_admin_covers_assets_diagnostics_downloads_and_ops_errors() {
config.local_ai.runtime_enabled = true;
config.local_ai.opt_in_confirmed = true;
config.local_ai.base_url = Some(base.clone());
config.local_ai.chat_model_id = "gemma4:e4b-it-q8_0".to_string();
config.local_ai.chat_model_id = "gemma3n:e4b-it-q8_0".to_string();
config.local_ai.embedding_model_id = "bge-m3".to_string();
config.local_ai.vision_model_id = "missing-vision".to_string();
config.local_ai.selected_tier = Some("custom".to_string());
@@ -134,7 +134,7 @@ async fn local_admin_covers_assets_diagnostics_downloads_and_ops_errors() {
.as_array()
.unwrap()
.iter()
.any(|issue| issue.as_str().unwrap().contains("gemma4:e4b-it-q8_0")));
.any(|issue| issue.as_str().unwrap().contains("gemma3n:e4b-it-q8_0")));
let assets = service.assets_status(&config).await.expect("assets");
assert!(assets.ollama_available);
@@ -175,7 +175,7 @@ async fn local_admin_covers_assets_diagnostics_downloads_and_ops_errors() {
.lock()
.expect("models")
.iter()
.any(|m| m == "gemma4:e4b-it-q8_0"));
.any(|m| m == "gemma3n:e4b-it-q8_0"));
let mut lm_config = config.clone();
lm_config.local_ai.provider = "lmstudio".to_string();
@@ -220,7 +220,7 @@ async fn local_admin_covers_assets_diagnostics_downloads_and_ops_errors() {
.await
.expect("ops progress")
.value;
assert_eq!(ops_progress.chat.id, "gemma4:e4b-it-q8_0");
assert_eq!(ops_progress.chat.id, "gemma3n:e4b-it-q8_0");
let ops_asset = local_ai_download_asset(&config, "embedding")
.await
@@ -529,7 +529,7 @@ async fn ollama_show(Json(body): Json<Value>) -> impl IntoResponse {
.into_response();
}
let context = match model {
"gemma4:e4b-it-q8_0" => 1024,
"gemma3n:e4b-it-q8_0" => 1024,
"bge-m3" => 8192,
_ => 4096,
};
@@ -545,7 +545,7 @@ async fn ollama_show(Json(body): Json<Value>) -> impl IntoResponse {
async fn ollama_pull(State(state): State<MockState>, Json(body): Json<Value>) -> impl IntoResponse {
let name = body["name"]
.as_str()
.unwrap_or("gemma4:e4b-it-q8_0")
.unwrap_or("gemma3n:e4b-it-q8_0")
.to_string();
state.ollama_models.lock().expect("models").push(name);
let body = [
@@ -295,7 +295,7 @@ async fn local_admin_covers_diagnostics_errors_assets_status_and_shutdown_with_f
config.local_ai.runtime_enabled = true;
config.local_ai.opt_in_confirmed = true;
config.local_ai.base_url = Some(base.clone());
config.local_ai.chat_model_id = "gemma4:e4b-it-q8_0".to_string();
config.local_ai.chat_model_id = "gemma3n:e4b-it-q8_0".to_string();
config.local_ai.embedding_model_id = "all-minilm:latest".to_string();
config.local_ai.selected_tier = Some("custom".to_string());
config.local_ai.preload_embedding_model = true;
@@ -325,7 +325,7 @@ async fn local_admin_covers_diagnostics_errors_assets_status_and_shutdown_with_f
assert!(issues.iter().any(|issue| issue
.as_str()
.unwrap()
.contains("Chat model `gemma4:e4b-it-q8_0`")));
.contains("Chat model `gemma3n:e4b-it-q8_0`")));
assert!(issues.iter().any(|issue| issue
.as_str()
.unwrap()