fix(llm): purge unavailable DashScope models + protocol-aware discovery probe

This commit is contained in:
matevip 2026-04-16 18:13:13 +08:00
parent b3c6b5a654
commit ba086d75f2
10 changed files with 273 additions and 20 deletions

View File

@ -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;
}
}

View File

@ -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<String> 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<String> 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<ModelInfoDTO> 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<String> 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<ModelInfoDTO> 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.
* <p>
* Currently only DashScope is filtered: the compatible-mode listing includes
* many models the native SDK rejects. Other providers pass through unchanged.
*/
private List<ModelInfoDTO> applyProtocolFilter(List<ModelInfoDTO> discovered,
ModelProtocol protocol,
String providerId) {
if (protocol != ModelProtocol.DASHSCOPE_NATIVE) {
return discovered;
}
int before = discovered.size();
List<ModelInfoDTO> 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<ModelInfoDTO> probeInParallel(List<ModelInfoDTO> 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<CompletableFuture<Void>> 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<String> modelIds) {
modelProviderService.getProviderConfig(providerId);
ModelProviderEntity provider = modelProviderService.getProviderConfig(providerId);
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
Set<String> 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}).
* <p>
* 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<String, Object> 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) {

View File

@ -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),

View File

@ -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),

View File

@ -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),

View File

@ -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),

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -51,6 +51,9 @@
<input type="checkbox" :value="model.id" :checked="selectedNewModelIds.includes(model.id)" @change="$emit('toggleModel', model.id)" />
<span class="discover-model-name">{{ model.name }}</span>
<span class="discover-model-id">{{ model.id }}</span>
<span v-if="model.probeOk === true" class="probe-badge probe-ok" title="Verified reachable">
verified
</span>
</label>
</div>
<div class="discover-actions">
@ -63,6 +66,17 @@
({{ selectedNewModelIds.length }})
</button>
</div>
<!-- Show models that were discovered but failed the probe, so users see
why they are not offered in the "add selected" list -->
<div v-if="discoveredUnavailable.length > 0" class="discover-unavailable">
<div class="discover-unavailable-title">
{{ discoveredUnavailable.length }} discovered model(s) failed the reachability probe and were not listed above:
</div>
<div v-for="model in discoveredUnavailable" :key="model.id" class="discover-unavailable-item">
<span class="discover-model-id">{{ model.id }}</span>
<span class="discover-unavailable-reason">{{ model.probeError || 'not reachable' }}</span>
</div>
</div>
</div>
</div>
@ -136,12 +150,13 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
const { t } = useI18n()
defineProps<{
const props = defineProps<{
show: boolean
provider: ProviderInfo | null
modelForm: { id: string; name: string }
@ -158,6 +173,13 @@ defineProps<{
onIconError: (e: Event) => void
}>()
// Models from discovery that failed the probe shown in a warning block
// so users understand why they are not in the "add selected" list
const discoveredUnavailable = computed(() => {
const all = props.discoverResult?.discoveredModels || []
return all.filter(m => m && m.probeOk === false)
})
defineEmits<{
close: []
discover: []
@ -244,6 +266,12 @@ defineEmits<{
.discover-checkbox:hover { background: var(--mc-bg-sunken); }
.discover-model-name { font-weight: 500; color: var(--mc-text-primary); }
.discover-model-id { color: var(--mc-text-tertiary); margin-left: auto; font-size: 12px; }
.probe-badge { font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600; margin-left: 6px; }
.probe-ok { background: rgba(34, 197, 94, 0.12); color: rgb(21, 128, 61); }
.discover-unavailable { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: rgba(234, 179, 8, 0.08); border: 1px solid rgba(234, 179, 8, 0.3); }
.discover-unavailable-title { font-size: 12px; font-weight: 600; color: rgb(161, 98, 7); margin-bottom: 6px; }
.discover-unavailable-item { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; font-size: 12px; }
.discover-unavailable-reason { color: var(--mc-text-tertiary); margin-left: 10px; max-width: 60%; text-align: right; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.discover-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
.model-list { display: grid; gap: 12px; }