mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +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 实例。
|
* 根据 AgentEntity 构建完整的 Agent 实例。
|
||||||
*
|
*
|
||||||
@ -243,14 +254,17 @@ public class AgentGraphBuilder {
|
|||||||
// Agents and conversations without an explicit choice.
|
// Agents and conversations without an explicit choice.
|
||||||
ModelConfigEntity globalDefault;
|
ModelConfigEntity globalDefault;
|
||||||
boolean explicitPinHonoured;
|
boolean explicitPinHonoured;
|
||||||
|
boolean agentOverrideHonoured;
|
||||||
try {
|
try {
|
||||||
explicitPinHonoured = pinResolvesToEnabledModel(modelProvider, modelName);
|
explicitPinHonoured = pinResolvesToEnabledModel(modelProvider, modelName);
|
||||||
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
|
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
|
||||||
|
agentOverrideHonoured = !explicitPinHonoured
|
||||||
|
&& agentModelOverrideResolved(entity, globalDefault);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent:请先在「设置 → 模型」中配置并启用默认模型");
|
||||||
}
|
}
|
||||||
ModelConfigEntity runtimeModel;
|
ModelConfigEntity runtimeModel;
|
||||||
if (explicitPinHonoured) {
|
if (explicitPinHonoured || agentOverrideHonoured) {
|
||||||
// The caller (admin UI / chat console) handed us a concrete
|
// The caller (admin UI / chat console) handed us a concrete
|
||||||
// (provider, model) pin and it points to an enabled row. Honour
|
// (provider, model) pin and it points to an enabled row. Honour
|
||||||
// it verbatim — running providerRouter.selectPrimary here would
|
// it verbatim — running providerRouter.selectPrimary here would
|
||||||
|
|||||||
@ -176,49 +176,74 @@ public class ProviderRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick a primary {@link ModelConfigEntity} that satisfies as many
|
* Pick a primary model using a two-pass strategy.
|
||||||
* required modalities as possible. Falls back to the global default
|
|
||||||
* when nothing better is configured.
|
|
||||||
*
|
*
|
||||||
* <p>Logic: try each preferred provider in turn; for each, ask
|
* <p>Pass 1 (capability-gated): preferred providers → global default.
|
||||||
* {@link ModelProviderService#getDefaultModelByProvider} for its
|
* <p>Pass 2 (unconstrained fallback): preferred providers → global default.
|
||||||
* default chat model and check capability resolution. First match
|
*
|
||||||
* wins. If nothing matches, return the global default unchanged.
|
* <p>When no preferred providers are configured the preferred branches
|
||||||
|
* are skipped, preserving the legacy behaviour.
|
||||||
*/
|
*/
|
||||||
public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) {
|
public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) {
|
||||||
if (agentId == null) return 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);
|
Set<String> needs = aggregateModelNeeds(agentId);
|
||||||
if (needs.isEmpty()) return globalDefault;
|
if (needs == null || needs.isEmpty()) return null;
|
||||||
Set<Modality> requiredModalities = needs.stream()
|
Set<Modality> mods = needs.stream()
|
||||||
.map(this::mapToModality)
|
.map(this::mapToModality)
|
||||||
.filter(java.util.Objects::nonNull)
|
.filter(java.util.Objects::nonNull)
|
||||||
.collect(java.util.stream.Collectors.toCollection(
|
.collect(java.util.stream.Collectors.toCollection(
|
||||||
() -> EnumSet.noneOf(Modality.class)));
|
() -> 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);
|
private boolean satisfies(ModelConfigEntity model, Set<Modality> required) {
|
||||||
for (String providerId : preferred) {
|
return capabilityService.resolve(model.getModelName(), model.getModalities())
|
||||||
ModelConfigEntity candidate = pickProviderDefault(providerId);
|
.containsAll(required);
|
||||||
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 ModelConfigEntity pickProviderDefault(String providerId) {
|
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