mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): 偏好提供商作为主模型选择依据 (#223)
偏好提供商从「仅 capability 触发」改为两轮筛选,使 Agent 偏好提供商能决定主模型选择;并在 Agent 显式配置 modelName 时优先 honour,不被偏好提供商覆盖。 Closes #222
This commit is contained in:
parent
6445f082a6
commit
5746bcf8cc
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user