feat(agent): 偏好提供商作为主模型选择依据 (#223)

偏好提供商从「仅 capability 触发」改为两轮筛选,使 Agent 偏好提供商能决定主模型选择;并在 Agent 显式配置 modelName 时优先 honour,不被偏好提供商覆盖。

Closes #222
This commit is contained in:
倪程伟 2026-06-03 23:33:14 +08:00 committed by GitHub
parent 6445f082a6
commit 5746bcf8cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 225 additions and 34 deletions

View File

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

View File

@ -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.
*
* <p>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.
* <p>Pass 1 (capability-gated): preferred providers global default.
* <p>Pass 2 (unconstrained fallback): preferred providers global default.
*
* <p>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<String> preferred = bindingService.getPreferredProviderIds(agentId);
Set<Modality> 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<Modality> resolveRequiredModalities(Long agentId) {
Set<String> needs = aggregateModelNeeds(agentId);
if (needs.isEmpty()) return globalDefault;
Set<Modality> requiredModalities = needs.stream()
if (needs == null || needs.isEmpty()) return null;
Set<Modality> 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<Modality> resolved = capabilityService.resolve(
globalDefault.getModelName(), globalDefault.getModalities());
if (resolved.containsAll(requiredModalities)) return globalDefault;
}
List<String> preferred = bindingService.getPreferredProviderIds(agentId);
for (String providerId : preferred) {
ModelConfigEntity candidate = pickProviderDefault(providerId);
if (candidate == null) continue;
EnumSet<Modality> 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<Modality> required) {
return capabilityService.resolve(model.getModelName(), model.getModalities())
.containsAll(required);
}
private ModelConfigEntity pickProviderDefault(String providerId) {

View File

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