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