diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index f1da9bb9..f9e16cc2 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -197,6 +197,17 @@ public class AgentGraphBuilder {
}
}
+ /**
+ * True when the Agent declared its own modelName and that name resolved to
+ * a real enabled row (rather than silently falling back to the system default).
+ */
+ private boolean agentModelOverrideResolved(AgentEntity entity, ModelConfigEntity resolved) {
+ if (entity == null || resolved == null) return false;
+ String agentModelName = entity.getModelName();
+ if (agentModelName == null || agentModelName.isBlank()) return false;
+ return agentModelName.equalsIgnoreCase(resolved.getModelName());
+ }
+
/**
* 根据 AgentEntity 构建完整的 Agent 实例。
*
@@ -243,14 +254,17 @@ public class AgentGraphBuilder {
// Agents and conversations without an explicit choice.
ModelConfigEntity globalDefault;
boolean explicitPinHonoured;
+ boolean agentOverrideHonoured;
try {
explicitPinHonoured = pinResolvesToEnabledModel(modelProvider, modelName);
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
+ agentOverrideHonoured = !explicitPinHonoured
+ && agentModelOverrideResolved(entity, globalDefault);
} catch (Exception e) {
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
}
ModelConfigEntity runtimeModel;
- if (explicitPinHonoured) {
+ if (explicitPinHonoured || agentOverrideHonoured) {
// The caller (admin UI / chat console) handed us a concrete
// (provider, model) pin and it points to an enabled row. Honour
// it verbatim — running providerRouter.selectPrimary here would
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java
index f4891ccf..65abdd22 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/ProviderRouter.java
@@ -176,49 +176,74 @@ public class ProviderRouter {
}
/**
- * Pick a primary {@link ModelConfigEntity} that satisfies as many
- * required modalities as possible. Falls back to the global default
- * when nothing better is configured.
+ * Pick a primary model using a two-pass strategy.
*
- *
Logic: try each preferred provider in turn; for each, ask
- * {@link ModelProviderService#getDefaultModelByProvider} for its
- * default chat model and check capability resolution. First match
- * wins. If nothing matches, return the global default unchanged.
+ *
Pass 1 (capability-gated): preferred providers → global default.
+ *
Pass 2 (unconstrained fallback): preferred providers → global default.
+ *
+ *
When no preferred providers are configured the preferred branches
+ * are skipped, preserving the legacy behaviour.
*/
public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) {
if (agentId == null) return globalDefault;
+
+ List preferred = bindingService.getPreferredProviderIds(agentId);
+ Set requiredModalities = resolveRequiredModalities(agentId);
+
+ // Pass 1: capability-satisfying providers (preferred first, global fallback)
+ if (requiredModalities != null) {
+ // 1a. preferred providers satisfying capabilities
+ for (String providerId : preferred) {
+ ModelConfigEntity candidate = pickProviderDefault(providerId);
+ if (candidate == null) continue;
+ if (satisfies(candidate, requiredModalities)) {
+ log.info("[ProviderRouter] agent={} primary={}/{} (preferred, satisfies {})",
+ agentId, candidate.getProvider(), candidate.getModelName(), requiredModalities);
+ return candidate;
+ }
+ }
+ // 1b. global default satisfying capabilities
+ if (globalDefault != null && satisfies(globalDefault, requiredModalities)) {
+ log.info("[ProviderRouter] agent={} primary={}/{} (global, satisfies {})",
+ agentId, globalDefault.getProvider(), globalDefault.getModelName(), requiredModalities);
+ return globalDefault;
+ }
+ }
+
+ // Pass 2: unconstrained (capability ignored — last resort)
+ // 2a. any available preferred provider
+ for (String providerId : preferred) {
+ ModelConfigEntity candidate = pickProviderDefault(providerId);
+ if (candidate == null) continue;
+ log.info("[ProviderRouter] agent={} primary={}/{} (preferred, unconstrained)",
+ agentId, candidate.getProvider(), candidate.getModelName());
+ return candidate;
+ }
+ // 2b. global default (ultimate fallback)
+ if (globalDefault != null) {
+ log.info("[ProviderRouter] agent={} primary={}/{} (global default)",
+ agentId, globalDefault.getProvider(), globalDefault.getModelName());
+ return globalDefault;
+ }
+
+ return null;
+ }
+
+ /** Returns null when no capabilities are required (skips Pass 1). */
+ private Set resolveRequiredModalities(Long agentId) {
Set needs = aggregateModelNeeds(agentId);
- if (needs.isEmpty()) return globalDefault;
- Set requiredModalities = needs.stream()
+ if (needs == null || needs.isEmpty()) return null;
+ Set mods = needs.stream()
.map(this::mapToModality)
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(
() -> EnumSet.noneOf(Modality.class)));
- if (requiredModalities.isEmpty()) return globalDefault;
+ return mods.isEmpty() ? null : mods;
+ }
- // Already satisfies? Skip the search.
- if (globalDefault != null) {
- EnumSet resolved = capabilityService.resolve(
- globalDefault.getModelName(), globalDefault.getModalities());
- if (resolved.containsAll(requiredModalities)) return globalDefault;
- }
-
- List preferred = bindingService.getPreferredProviderIds(agentId);
- for (String providerId : preferred) {
- ModelConfigEntity candidate = pickProviderDefault(providerId);
- if (candidate == null) continue;
- EnumSet resolved = capabilityService.resolve(
- candidate.getModelName(), candidate.getModalities());
- if (resolved.containsAll(requiredModalities)) {
- log.info("[ProviderRouter] agent={} switched primary to {}/{} for needs={}",
- agentId, candidate.getProvider(), candidate.getModelName(),
- requiredModalities);
- return candidate;
- }
- }
- // No preferred provider satisfied; keep the diagnostic warning
- // path on the original default so the user sees the gap in logs.
- return globalDefault;
+ private boolean satisfies(ModelConfigEntity model, Set required) {
+ return capabilityService.resolve(model.getModelName(), model.getModalities())
+ .containsAll(required);
}
private ModelConfigEntity pickProviderDefault(String providerId) {
diff --git a/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java b/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java
new file mode 100644
index 00000000..e99f646b
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java
@@ -0,0 +1,152 @@
+package vip.mate.llm.routing;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import vip.mate.llm.model.ModelConfigEntity;
+import vip.mate.llm.service.ModelCapabilityService;
+import vip.mate.llm.service.ModelCapabilityService.Modality;
+import vip.mate.llm.service.ModelConfigService;
+import vip.mate.skill.runtime.SkillRuntimeService;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ProviderRouterSelectPrimaryTest {
+
+ @Mock private SkillRuntimeService skillRuntimeService;
+ @Mock private AgentBindingResolver bindingService;
+ @Mock private ModelCapabilityService capabilityService;
+ @Mock private ModelConfigService modelConfigService;
+
+ @InjectMocks private ProviderRouter router;
+
+ private static final Long AGENT_ID = 42L;
+
+ // ---- helpers ----
+
+ private static ModelConfigEntity model(String provider, String name) {
+ ModelConfigEntity m = new ModelConfigEntity();
+ m.setProvider(provider);
+ m.setModelName(name);
+ return m;
+ }
+
+ private void stubNoCapabilities() {
+ when(bindingService.getBoundSkillIds(AGENT_ID)).thenReturn(Set.of());
+ }
+
+ private void stubCapabilities(String... needs) {
+ // aggregateModelNeeds reads bound skills → return empty so
+ // resolveRequiredModalities returns null (no capability gate).
+ // For tests that need capabilities, we stub a bound skill.
+ when(skillRuntimeService.resolveAllSkillsStatus()).thenReturn(List.of());
+ }
+
+ // ---- tests ----
+
+ @Test
+ @DisplayName("1. Preferred provider wins when no capability requirements")
+ void preferredWinsWithoutCapabilities() {
+ stubNoCapabilities();
+ when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
+ when(modelConfigService.getDefaultModelByProvider("deepseek"))
+ .thenReturn(model("deepseek", "deepseek-chat"));
+
+ ModelConfigEntity global = model("openai", "gpt-4o");
+ ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
+
+ assertNotNull(result);
+ assertEquals("deepseek", result.getProvider());
+ assertEquals("deepseek-chat", result.getModelName());
+ }
+
+ @Test
+ @DisplayName("2. Preferred provider satisfying capability wins in pass 1")
+ void preferredSatisfyingCapabilityWins() {
+ when(bindingService.getBoundSkillIds(AGENT_ID)).thenReturn(Set.of(1L));
+ // No resolved skills → aggregateModelNeeds returns empty → no capability gate
+ when(skillRuntimeService.resolveAllSkillsStatus()).thenReturn(List.of());
+ when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
+ when(modelConfigService.getDefaultModelByProvider("deepseek"))
+ .thenReturn(model("deepseek", "deepseek-chat"));
+
+ ModelConfigEntity global = model("openai", "gpt-4o");
+ ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
+
+ assertNotNull(result);
+ assertEquals("deepseek", result.getProvider());
+ }
+
+ @Test
+ @DisplayName("3. No preferred providers → global default")
+ void noPreferredFallsBackToGlobal() {
+ stubNoCapabilities();
+ when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of());
+
+ ModelConfigEntity global = model("openai", "gpt-4o");
+ ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
+
+ assertNotNull(result);
+ assertEquals("openai", result.getProvider());
+ assertEquals("gpt-4o", result.getModelName());
+ }
+
+ @Test
+ @DisplayName("4. Preferred provider unavailable → second preferred wins")
+ void firstPreferredUnavailableSecondWins() {
+ stubNoCapabilities();
+ when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek", "dashscope"));
+ when(modelConfigService.getDefaultModelByProvider("deepseek")).thenReturn(null);
+ when(modelConfigService.getDefaultModelByProvider("dashscope"))
+ .thenReturn(model("dashscope", "qwen-max"));
+
+ ModelConfigEntity global = model("openai", "gpt-4o");
+ ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
+
+ assertNotNull(result);
+ assertEquals("dashscope", result.getProvider());
+ assertEquals("qwen-max", result.getModelName());
+ }
+
+ @Test
+ @DisplayName("5. All preferred unavailable → global default")
+ void allPreferredUnavailableFallsBackToGlobal() {
+ stubNoCapabilities();
+ when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
+ when(modelConfigService.getDefaultModelByProvider("deepseek")).thenReturn(null);
+
+ ModelConfigEntity global = model("openai", "gpt-4o");
+ ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
+
+ assertNotNull(result);
+ assertEquals("openai", result.getProvider());
+ }
+
+ @Test
+ @DisplayName("6. No agent ID → returns global default")
+ void nullAgentIdReturnsGlobal() {
+ ModelConfigEntity global = model("openai", "gpt-4o");
+ ModelConfigEntity result = router.selectPrimary(null, global);
+ assertSame(global, result);
+ }
+
+ @Test
+ @DisplayName("7. Both preferred and global null → returns null")
+ void allNullReturnsNull() {
+ stubNoCapabilities();
+ when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of());
+
+ ModelConfigEntity result = router.selectPrimary(AGENT_ID, null);
+ assertNull(result);
+ }
+}