diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java index 17cda515..0c421ce8 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java @@ -10,4 +10,20 @@ import lombok.NoArgsConstructor; public class ModelInfoDTO { private String id; private String name; + + /** + * Discovery probe result. true = passed runtime-protocol ping test, + * false = ping failed (listed by provider but unusable at runtime, + * e.g. DashScope compatible-mode may list models the native SDK rejects). + * null = not probed (probe disabled or still pending). + */ + private Boolean probeOk; + + /** Reason text when probeOk=false (short, suitable for UI badge tooltip) */ + private String probeError; + + public ModelInfoDTO(String id, String name) { + this.id = id; + this.name = name; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java index 1f728e03..9604f026 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java @@ -14,6 +14,12 @@ import vip.mate.llm.model.*; import java.time.Duration; import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; @Slf4j @@ -27,6 +33,42 @@ public class ModelDiscoveryService { private static final Duration TIMEOUT = Duration.ofSeconds(10); + // Virtual-thread executor for parallel model probing (lightweight, short-lived) + private static final ExecutorService PROBE_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); + + // Probe concurrency cap — avoid flooding the provider with concurrent ping requests + private static final int MAX_PROBE_CONCURRENCY = 5; + + // Per-model probe timeout (short; we only need to know "yes/no usable") + private static final long PROBE_TIMEOUT_SECONDS = 12; + + /** + * Explicit deny list: model ids listed by DashScope compatible-mode that are known + * to fail on the native protocol. Updated as we observe new failures. + */ + private static final Set DASHSCOPE_NATIVE_DENY = Set.of( + "qwen3.5-max", + "qwen3.5-plus" + ); + + /** + * Allow-list prefixes for DashScope models that are known to work on the native + * protocol. An empty set means "no prefix filter" (we still apply DENY). + * Extend conservatively as we verify additional families. + */ + private static final Set DASHSCOPE_NATIVE_ALLOW_PREFIXES = Set.of( + "qwen-", // qwen-max / qwen-plus / qwen-turbo / qwen-coder-* / qwen-long + "qwen2-", // qwen2 series + "qwen3-", // qwen3-max / qwen3-plus / qwen3-coder / qwen3-235b-* + "qwen-vl-", // vision-language + "qwen-audio-", + "qwen-omni-", + "deepseek-", // deepseek-v3.x + "baichuan", + "yi-", + "llama" + ); + // ==================== 模型发现 ==================== public DiscoverResult discoverModels(String providerId) { @@ -38,17 +80,118 @@ public class ModelDiscoveryService { ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel()); List discovered = fetchRemoteModels(provider, protocol); - // 去重:对比已有模型 + // Layer 2: Protocol-aware allow/deny filtering. The listing endpoint + // (compatible-mode /v1/models for DashScope) often returns models that + // the native SDK does not accept — filter them out before the user sees + // them. + discovered = applyProtocolFilter(discovered, protocol, providerId); + + // Layer 3: Probe each remaining model with a real runtime-protocol call. + // This catches any model the allow-list let through but the provider + // actually rejects at request time. Failed probes are kept in the list + // but marked probeOk=false so the UI can show a warning badge. + discovered = probeInParallel(discovered, provider, protocol); + + // De-dupe against already-configured models for the "new" bucket Set existingIds = modelConfigService.listModelsByProvider(providerId).stream() .map(ModelConfigEntity::getModelName) .collect(Collectors.toSet()); + + // Only propose models that passed the probe (or were not probed) as "new" List newModels = discovered.stream() .filter(m -> !existingIds.contains(m.getId())) + .filter(m -> !Boolean.FALSE.equals(m.getProbeOk())) .toList(); return new DiscoverResult(discovered, newModels, discovered.size(), newModels.size()); } + /** + * Apply protocol-aware allow/deny filtering to the raw discovery list. + *

+ * Currently only DashScope is filtered: the compatible-mode listing includes + * many models the native SDK rejects. Other providers pass through unchanged. + */ + private List applyProtocolFilter(List discovered, + ModelProtocol protocol, + String providerId) { + if (protocol != ModelProtocol.DASHSCOPE_NATIVE) { + return discovered; + } + int before = discovered.size(); + List filtered = discovered.stream() + .filter(m -> { + String id = m.getId(); + if (id == null || id.isBlank()) return false; + String lower = id.toLowerCase(); + if (DASHSCOPE_NATIVE_DENY.contains(lower)) return false; + // Allow if any allowed prefix matches; if allow-list is empty, permit everything + if (DASHSCOPE_NATIVE_ALLOW_PREFIXES.isEmpty()) return true; + return DASHSCOPE_NATIVE_ALLOW_PREFIXES.stream().anyMatch(lower::startsWith); + }) + .toList(); + if (filtered.size() < before) { + log.info("[ModelDiscovery] Filtered {} -> {} DashScope models for provider={} (allow/deny rules)", + before, filtered.size(), providerId); + } + return filtered; + } + + /** + * Probe each discovered model in parallel (bounded concurrency) using the same + * protocol the runtime will use. Populates {@code probeOk}/{@code probeError} + * on each DTO; does not remove failed entries so the UI can surface the reason. + */ + private List probeInParallel(List discovered, + ModelProviderEntity provider, + ModelProtocol protocol) { + if (discovered.isEmpty()) return discovered; + + // OpenAI ChatGPT has no model-level test, skip probe for it + if (protocol == ModelProtocol.OPENAI_CHATGPT) return discovered; + + Semaphore sem = new Semaphore(MAX_PROBE_CONCURRENCY); + List> futures = new ArrayList<>(discovered.size()); + for (ModelInfoDTO dto : discovered) { + futures.add(CompletableFuture.runAsync(() -> { + try { sem.acquire(); } + catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; } + try { + sendTestPrompt(provider, protocol, dto.getId()); + dto.setProbeOk(true); + } catch (Exception e) { + dto.setProbeOk(false); + dto.setProbeError(shortError(e)); + log.info("[ModelDiscovery] Probe failed for model={}: {}", dto.getId(), dto.getProbeError()); + } finally { + sem.release(); + } + }, PROBE_EXECUTOR)); + } + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .get(PROBE_TIMEOUT_SECONDS * Math.max(1, discovered.size() / MAX_PROBE_CONCURRENCY + 1), + TimeUnit.SECONDS); + } catch (TimeoutException te) { + log.warn("[ModelDiscovery] Probe batch timeout; {} models may be marked unknown", + futures.stream().filter(f -> !f.isDone()).count()); + } catch (Exception e) { + log.warn("[ModelDiscovery] Probe batch wait failed: {}", e.getMessage()); + } + long passed = discovered.stream().filter(m -> Boolean.TRUE.equals(m.getProbeOk())).count(); + long failed = discovered.stream().filter(m -> Boolean.FALSE.equals(m.getProbeOk())).count(); + log.info("[ModelDiscovery] Probe results: {} passed, {} failed, {} unknown (of {})", + passed, failed, discovered.size() - passed - failed, discovered.size()); + return discovered; + } + + private String shortError(Exception e) { + String msg = extractErrorMessage(e); + if (msg == null) return "unknown error"; + // Clip to ~120 chars so the UI tooltip stays usable + return msg.length() > 120 ? msg.substring(0, 120) + "..." : msg; + } + // ==================== 连接测试 ==================== public TestResult testConnection(String providerId) { @@ -99,17 +242,29 @@ public class ModelDiscoveryService { // ==================== 批量添加发现的模型 ==================== public int batchAddModels(String providerId, List modelIds) { - modelProviderService.getProviderConfig(providerId); + ModelProviderEntity provider = modelProviderService.getProviderConfig(providerId); + ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel()); Set existingIds = modelConfigService.listModelsByProvider(providerId).stream() .map(ModelConfigEntity::getModelName) .collect(Collectors.toSet()); int added = 0; + int skipped = 0; for (String modelId : modelIds) { - if (!existingIds.contains(modelId)) { - modelConfigService.addModelToProvider(providerId, modelId, modelId, false); - added++; + if (modelId == null || modelId.isBlank()) continue; + if (existingIds.contains(modelId)) continue; + // Defense-in-depth: never add a DashScope model that is on the native protocol deny list + if (protocol == ModelProtocol.DASHSCOPE_NATIVE + && DASHSCOPE_NATIVE_DENY.contains(modelId.toLowerCase())) { + log.warn("[ModelDiscovery] Refusing to add {} — on DashScope native deny list", modelId); + skipped++; + continue; } + modelConfigService.addModelToProvider(providerId, modelId, modelId, false); + added++; + } + if (skipped > 0) { + log.info("[ModelDiscovery] batchAddModels: added={}, skipped(deny)={}", added, skipped); } return added; } @@ -252,30 +407,65 @@ public class ModelDiscoveryService { return extractOpenAiChatContent(body); } + /** + * Test a DashScope model using the **native** endpoint + * ({@code /api/v1/services/aigc/text-generation/generation}). + *

+ * This matches the protocol Spring AI Alibaba's {@code DashScopeChatModel} uses + * at runtime. Using compatible-mode for testing (as the previous implementation + * did) was the root cause of "test passed but chat fails" — compatible-mode + * accepts a broader set of model names than the native API does. + */ private String sendDashScopeTestPrompt(ModelProviderEntity provider, String modelId) { String apiKey = provider.getApiKey(); if (!modelProviderService.hasUsableApiKey(apiKey)) { throw new MateClawException("err.llm.dashscope_key_missing", "DashScope API Key 未配置"); } + // DashScope native request shape: input.messages + parameters Map requestBody = Map.of( "model", modelId, - "messages", List.of(Map.of("role", "user", "content", "请回复:连接正常")), - "max_tokens", 10, - "temperature", 0 + "input", Map.of( + "messages", List.of(Map.of("role", "user", "content", "ping")) + ), + "parameters", Map.of( + "max_tokens", 1, + "temperature", 0, + "result_format", "message" + ) ); String body = RestClient.builder() - .baseUrl("https://dashscope.aliyuncs.com/compatible-mode") + .baseUrl("https://dashscope.aliyuncs.com") .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim()) .build() .post() - .uri("/v1/chat/completions") + .uri("/api/v1/services/aigc/text-generation/generation") .body(requestBody) .retrieve() .body(String.class); - return extractOpenAiChatContent(body); + return extractDashScopeNativeContent(body); + } + + /** + * Extract content from DashScope native response: + * {@code { "output": { "choices": [ { "message": { "content": "..." } } ] } } } + * Falls back to the raw body preview if the shape differs. + */ + private String extractDashScopeNativeContent(String body) { + try { + JsonNode root = objectMapper.readTree(body); + JsonNode choices = root.path("output").path("choices"); + if (choices.isArray() && choices.size() > 0) { + String content = choices.get(0).path("message").path("content").asText(""); + if (!content.isBlank()) return content; + } + // Older shape: output.text + String legacyText = root.path("output").path("text").asText(""); + if (!legacyText.isBlank()) return legacyText; + } catch (Exception ignored) {} + return body == null ? "" : (body.length() > 200 ? body.substring(0, 200) : body); } private String sendGeminiTestPrompt(ModelProviderEntity provider, String modelId) { diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index e01872fe..2d3b62b0 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -159,8 +159,7 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (1000000101, 'Qwen3 Max', 'dashscope', 'qwen3-max', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000102, 'Qwen3 235B A22B Thinking', 'dashscope', 'qwen3-235b-a22b-thinking-2507', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000103, 'DeepSeek-V3.2', 'dashscope', 'deepseek-v3.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000170, 'Qwen3.5 Plus', 'dashscope', 'qwen3.5-plus', 'Qwen 3.5 series latest balanced model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000171, 'Qwen3.5 Max', 'dashscope', 'qwen3.5-max', 'Qwen 3.5 series strongest model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Removed: qwen3.5-plus / qwen3.5-max — unavailable on DashScope native protocol (returns 400 InvalidParameter) (1000000172, 'Qwen3 Plus', 'dashscope', 'qwen3-plus', 'Qwen3 balanced model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000173, 'Qwen Long', 'dashscope', 'qwen-long', 'Long-context model with extended context support', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000104, 'Qwen3.5-122B-A10B', 'modelscope', 'Qwen/Qwen3.5-122B-A10B', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index 97765177..6905fc70 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -160,8 +160,7 @@ VALUES (1000000101, 'Qwen3 Max', 'dashscope', 'qwen3-max', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000102, 'Qwen3 235B A22B Thinking', 'dashscope', 'qwen3-235b-a22b-thinking-2507', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000103, 'DeepSeek-V3.2', 'dashscope', 'deepseek-v3.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000170, 'Qwen3.5 Plus', 'dashscope', 'qwen3.5-plus', 'Qwen 3.5 series latest balanced model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000171, 'Qwen3.5 Max', 'dashscope', 'qwen3.5-max', 'Qwen 3.5 series strongest model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Removed: qwen3.5-plus / qwen3.5-max — unavailable on DashScope native protocol (returns 400 InvalidParameter) (1000000172, 'Qwen3 Plus', 'dashscope', 'qwen3-plus', 'Qwen3 balanced model', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000173, 'Qwen Long', 'dashscope', 'qwen-long', 'Long-context model with extended context support', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000104, 'Qwen3.5-122B-A10B', 'modelscope', 'Qwen/Qwen3.5-122B-A10B', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 45ef64f3..150d92c0 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -160,8 +160,7 @@ VALUES (1000000101, 'Qwen3 Max', 'dashscope', 'qwen3-max', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000102, 'Qwen3 235B A22B Thinking', 'dashscope', 'qwen3-235b-a22b-thinking-2507', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000103, 'DeepSeek-V3.2', 'dashscope', 'deepseek-v3.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000170, 'Qwen3.5 Plus', 'dashscope', 'qwen3.5-plus', 'Qwen 3.5 系列最新均衡模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000171, 'Qwen3.5 Max', 'dashscope', 'qwen3.5-max', 'Qwen 3.5 系列最强模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Removed: qwen3.5-plus / qwen3.5-max — unavailable on DashScope native protocol (returns 400 InvalidParameter) (1000000172, 'Qwen3 Plus', 'dashscope', 'qwen3-plus', 'Qwen3 均衡模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000173, 'Qwen Long', 'dashscope', 'qwen-long', '长文本模型,支持超长上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000104, 'Qwen3.5-122B-A10B', 'modelscope', 'Qwen/Qwen3.5-122B-A10B', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 3d8ee6b0..e945efdd 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -165,8 +165,7 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe (1000000101, 'Qwen3 Max', 'dashscope', 'qwen3-max', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000102, 'Qwen3 235B A22B Thinking', 'dashscope', 'qwen3-235b-a22b-thinking-2507', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000103, 'DeepSeek-V3.2', 'dashscope', 'deepseek-v3.2', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000170, 'Qwen3.5 Plus', 'dashscope', 'qwen3.5-plus', 'Qwen 3.5 系列最新均衡模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), -(1000000171, 'Qwen3.5 Max', 'dashscope', 'qwen3.5-max', 'Qwen 3.5 系列最强模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), +-- Removed: qwen3.5-plus / qwen3.5-max — unavailable on DashScope native protocol (returns 400 InvalidParameter) (1000000172, 'Qwen3 Plus', 'dashscope', 'qwen3-plus', 'Qwen3 均衡模型', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000173, 'Qwen Long', 'dashscope', 'qwen-long', '长文本模型,支持超长上下文', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), (1000000104, 'Qwen3.5-122B-A10B', 'modelscope', 'Qwen/Qwen3.5-122B-A10B', '', 0.7, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0), diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V15__purge_unavailable_dashscope_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V15__purge_unavailable_dashscope_models.sql new file mode 100644 index 00000000..0ae370f5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V15__purge_unavailable_dashscope_models.sql @@ -0,0 +1,12 @@ +-- V15: Purge DashScope model seed rows that are unavailable on the native protocol +-- These model names are listed by DashScope compatible-mode /v1/models but will fail +-- with 400 InvalidParameter or "url error, please check url" on the native endpoint +-- (/api/v1/services/aigc/text-generation/generation), which is what Spring AI Alibaba +-- DashScopeChatModel actually uses at runtime. Keeping them around only leads to user +-- confusion when conversations silently fail. +DELETE FROM mate_model_config +WHERE id IN (1000000170, 1000000171) + AND provider = 'dashscope' + AND builtin = TRUE; +-- 1000000170 = qwen3.5-plus +-- 1000000171 = qwen3.5-max diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V15__purge_unavailable_dashscope_models.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V15__purge_unavailable_dashscope_models.sql new file mode 100644 index 00000000..482f98e7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V15__purge_unavailable_dashscope_models.sql @@ -0,0 +1,7 @@ +-- V15: Purge DashScope model seed rows that are unavailable on the native protocol +DELETE FROM mate_model_config +WHERE id IN (1000000170, 1000000171) + AND provider = 'dashscope' + AND builtin = TRUE; +-- 1000000170 = qwen3.5-plus +-- 1000000171 = qwen3.5-max diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 0f0a3104..a557087b 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -574,6 +574,10 @@ export interface SystemSettings { export interface ProviderModelInfo { id: string name: string + /** Discovery probe result (backend `probeOk` field). True = verified reachable, false = probe failed, undefined = not probed */ + probeOk?: boolean + /** Short error message when probeOk=false */ + probeError?: string } export interface ProviderInfo { diff --git a/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue b/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue index aa7b07a5..6eee027a 100644 --- a/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue +++ b/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue @@ -51,6 +51,9 @@ {{ model.name }} {{ model.id }} + + ✓ verified +

@@ -63,6 +66,17 @@ ({{ selectedNewModelIds.length }})
+ +
+
+ ⚠ {{ discoveredUnavailable.length }} discovered model(s) failed the reachability probe and were not listed above: +
+
+ {{ model.id }} + {{ model.probeError || 'not reachable' }} +
+
@@ -136,12 +150,13 @@