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 a9f531c7..6866a4eb 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 @@ -139,6 +139,17 @@ public class ModelDiscoveryService { "qwen3-livetranslate-" ); + /** + * Model-id prefixes that mark an embedding model. These pass the chat-focused + * acceptable-id check (so the manual "Add model" form works) but must be + * excluded from chat-style runtime probes and from "new chat models" auto + * suggestions — they only speak the embeddings protocol, not chat completion. + */ + private static final Set EMBEDDING_MODEL_PREFIXES = Set.of( + "text-embedding-", + "embedding-" + ); + /** * Allow-list prefixes for DashScope models that are known to work on the native * protocol (chat or embedding). An empty set means "no prefix filter" (we still @@ -183,10 +194,13 @@ public class ModelDiscoveryService { .map(ModelConfigEntity::getModelName) .collect(Collectors.toSet()); - // Only propose models that passed the probe (or were not probed) as "new" + // Only propose models that passed the probe (or were not probed) as "new". + // Embedding models are catalog-visible but excluded from the chat-discovery + // suggestion bucket — adding them as chat models would 400 at runtime. List newModels = discovered.stream() .filter(m -> !existingIds.contains(m.getId())) .filter(m -> !Boolean.FALSE.equals(m.getProbeOk())) + .filter(m -> !isEmbeddingModelId(m.getId())) .toList(); return new DiscoverResult(discovered, newModels, discovered.size(), newModels.size()); @@ -235,6 +249,18 @@ public class ModelDiscoveryService { return DASHSCOPE_NATIVE_ALLOW_PREFIXES.stream().anyMatch(lower::startsWith); } + /** + * Returns true when a model id looks like an embedding model (e.g. + * {@code text-embedding-v3}). Used to skip chat-style probes and to keep + * embedding entries out of the "new chat models to add" auto-suggestion. + * Package-private for unit tests. + */ + static boolean isEmbeddingModelId(String modelId) { + if (modelId == null) return false; + String lower = modelId.toLowerCase(); + return EMBEDDING_MODEL_PREFIXES.stream().anyMatch(lower::startsWith); + } + /** * Defensive guard for code paths that persist a model id without going through * discovery (e.g. the manual "Add model" form). Throws a MateClawException with @@ -270,6 +296,13 @@ public class ModelDiscoveryService { Semaphore sem = new Semaphore(MAX_PROBE_CONCURRENCY); List> futures = new ArrayList<>(discovered.size()); for (ModelInfoDTO dto : discovered) { + if (isEmbeddingModelId(dto.getId())) { + // Embedding models don't speak chat completion — sendTestPrompt would + // 400 with InvalidParameter. Leave probeOk null (no warning badge) + // and surface a clear, non-error reason in probeError. + dto.setProbeError("Embedding model — not chat-probeable"); + continue; + } futures.add(CompletableFuture.runAsync(() -> { try { sem.acquire(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return; } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryEmbeddingDetectionTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryEmbeddingDetectionTest.java new file mode 100644 index 00000000..8d572f99 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryEmbeddingDetectionTest.java @@ -0,0 +1,61 @@ +package vip.mate.llm.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Coverage for {@link ModelDiscoveryService#isEmbeddingModelId(String)} — + * the predicate that keeps embedding models out of the chat-style probe and + * out of the chat-discovery "new models" suggestion bucket. + */ +class ModelDiscoveryEmbeddingDetectionTest { + + @Test + void dashscopeEmbeddingVariants_recognised() { + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-v1")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-v3")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-v4")); + } + + @Test + void openAiEmbeddingVariants_recognised() { + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-ada-002")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-3-small")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("text-embedding-3-large")); + } + + @Test + void genericEmbeddingPrefix_recognised() { + // Some providers ship just "embedding-..." without the "text-" prefix. + assertTrue(ModelDiscoveryService.isEmbeddingModelId("embedding-001")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("embedding-large")); + } + + @Test + void chatModels_notRecognisedAsEmbedding() { + assertFalse(ModelDiscoveryService.isEmbeddingModelId("qwen-plus")); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("gpt-4o")); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("claude-sonnet-4-6")); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("deepseek-r1")); + } + + @Test + void detectionIsCaseInsensitive() { + assertTrue(ModelDiscoveryService.isEmbeddingModelId("Text-Embedding-V4")); + assertTrue(ModelDiscoveryService.isEmbeddingModelId("TEXT-EMBEDDING-3-SMALL")); + } + + @Test + void nullAndBlank_notEmbedding() { + assertFalse(ModelDiscoveryService.isEmbeddingModelId(null)); + assertFalse(ModelDiscoveryService.isEmbeddingModelId("")); + } + + @Test + void stringContainingEmbeddingMidway_notDetected() { + // Only prefix-anchored matches count; arbitrary mentions don't. + assertFalse(ModelDiscoveryService.isEmbeddingModelId("qwen-embedding-experimental")); + } +}