mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
fix(llm): preferred provider now drives primary model selection — per-provider model resolution + unconfigured-provider skip
This commit is contained in:
parent
b22462105c
commit
76c6504527
@ -109,7 +109,7 @@ public class AgentBindingController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
// ==================== Provider Preferences ====================
|
||||||
|
|
||||||
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
|
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
|
||||||
@GetMapping("/provider-preferences")
|
@GetMapping("/provider-preferences")
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import vip.mate.llm.model.ModelConfigEntity;
|
|||||||
import vip.mate.llm.service.ModelCapabilityService;
|
import vip.mate.llm.service.ModelCapabilityService;
|
||||||
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
import vip.mate.skill.manifest.SkillManifest;
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
@ -45,6 +46,7 @@ public class ProviderRouter {
|
|||||||
private final AgentBindingResolver bindingService;
|
private final AgentBindingResolver bindingService;
|
||||||
private final ModelCapabilityService capabilityService;
|
private final ModelCapabilityService capabilityService;
|
||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
|
private final ModelProviderService modelProviderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the union of capability requirements declared by the
|
* Compute the union of capability requirements declared by the
|
||||||
@ -249,9 +251,15 @@ public class ProviderRouter {
|
|||||||
private ModelConfigEntity pickProviderDefault(String providerId) {
|
private ModelConfigEntity pickProviderDefault(String providerId) {
|
||||||
if (providerId == null || providerId.isBlank()) return null;
|
if (providerId == null || providerId.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
return modelConfigService.getDefaultModelByProvider(providerId);
|
// A provider without usable credentials can't serve as the primary
|
||||||
|
// model: selecting it would only be rejected downstream and fall
|
||||||
|
// back to the global default, silently skipping the remaining
|
||||||
|
// preferred providers. Skip it here so preference resolution
|
||||||
|
// continues to the next entry instead.
|
||||||
|
if (!modelProviderService.isProviderConfigured(providerId)) return null;
|
||||||
|
return modelConfigService.getPrimaryChatModelByProvider(providerId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// getDefaultModelByProvider can return null or throw when
|
// getPrimaryChatModelByProvider can return null or throw when
|
||||||
// the provider has no enabled chat model; treat both as
|
// the provider has no enabled chat model; treat both as
|
||||||
// "no candidate from this provider".
|
// "no candidate from this provider".
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -183,6 +183,32 @@ public class ModelConfigService {
|
|||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a provider's primary chat model for routing.
|
||||||
|
*
|
||||||
|
* <p>Prefers the row carrying the system default flag when it happens to
|
||||||
|
* belong to this provider; otherwise falls back to the provider's
|
||||||
|
* earliest-configured enabled chat model. The {@code is_default} flag is a
|
||||||
|
* single system-wide marker (see {@link #clearDefaultFlag}), so a provider
|
||||||
|
* that does not own it has no row matching {@link #getDefaultModelByProvider}.
|
||||||
|
* Without this fallback a preferred provider could never contribute a
|
||||||
|
* primary model unless it already held the global default.
|
||||||
|
*
|
||||||
|
* @return the provider's primary chat model, or {@code null} when the
|
||||||
|
* provider has no enabled chat model configured
|
||||||
|
*/
|
||||||
|
public ModelConfigEntity getPrimaryChatModelByProvider(String providerId) {
|
||||||
|
if (providerId == null || providerId.isBlank()) return null;
|
||||||
|
ModelConfigEntity def = getDefaultModelByProvider(providerId);
|
||||||
|
if (def != null) return def;
|
||||||
|
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||||
|
.eq(ModelConfigEntity::getProvider, providerId)
|
||||||
|
.eq(ModelConfigEntity::getEnabled, true)
|
||||||
|
.eq(ModelConfigEntity::getModelType, "chat")
|
||||||
|
.orderByAsc(ModelConfigEntity::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
public ModelConfigEntity createModel(ModelConfigEntity entity) {
|
public ModelConfigEntity createModel(ModelConfigEntity entity) {
|
||||||
validateModel(entity, null);
|
validateModel(entity, null);
|
||||||
if (Boolean.TRUE.equals(entity.getIsDefault())) {
|
if (Boolean.TRUE.equals(entity.getIsDefault())) {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import vip.mate.llm.model.ModelConfigEntity;
|
|||||||
import vip.mate.llm.service.ModelCapabilityService;
|
import vip.mate.llm.service.ModelCapabilityService;
|
||||||
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
import vip.mate.skill.manifest.SkillManifest;
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
@ -30,6 +31,7 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
@Mock private AgentBindingResolver bindingService;
|
@Mock private AgentBindingResolver bindingService;
|
||||||
@Mock private ModelCapabilityService capabilityService;
|
@Mock private ModelCapabilityService capabilityService;
|
||||||
@Mock private ModelConfigService modelConfigService;
|
@Mock private ModelConfigService modelConfigService;
|
||||||
|
@Mock private ModelProviderService modelProviderService;
|
||||||
|
|
||||||
@InjectMocks private ProviderRouter router;
|
@InjectMocks private ProviderRouter router;
|
||||||
|
|
||||||
@ -63,6 +65,17 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
when(skillRuntimeService.resolveAllSkillsStatus()).thenReturn(List.of(skill));
|
when(skillRuntimeService.resolveAllSkillsStatus()).thenReturn(List.of(skill));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub a preferred provider as configured (has usable credentials) and
|
||||||
|
* resolving to the given primary chat model. Mirrors the runtime path
|
||||||
|
* {@code pickProviderDefault} takes: a provider must be configured before
|
||||||
|
* its primary chat model is considered.
|
||||||
|
*/
|
||||||
|
private void stubConfiguredProvider(String providerId, ModelConfigEntity primaryModel) {
|
||||||
|
when(modelProviderService.isProviderConfigured(providerId)).thenReturn(true);
|
||||||
|
when(modelConfigService.getPrimaryChatModelByProvider(providerId)).thenReturn(primaryModel);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- tests ----
|
// ---- tests ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -70,8 +83,7 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
void preferredWinsWithoutCapabilities() {
|
void preferredWinsWithoutCapabilities() {
|
||||||
stubNoCapabilities();
|
stubNoCapabilities();
|
||||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||||
when(modelConfigService.getDefaultModelByProvider("deepseek"))
|
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
|
||||||
.thenReturn(model("deepseek", "deepseek-chat"));
|
|
||||||
|
|
||||||
ModelConfigEntity global = model("openai", "gpt-4o");
|
ModelConfigEntity global = model("openai", "gpt-4o");
|
||||||
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
||||||
@ -86,8 +98,7 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
void preferredSatisfyingCapabilityWins() {
|
void preferredSatisfyingCapabilityWins() {
|
||||||
bindSkillRequiring("vision");
|
bindSkillRequiring("vision");
|
||||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||||
when(modelConfigService.getDefaultModelByProvider("deepseek"))
|
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-vl"));
|
||||||
.thenReturn(model("deepseek", "deepseek-vl"));
|
|
||||||
when(capabilityService.resolve(eq("deepseek-vl"), any()))
|
when(capabilityService.resolve(eq("deepseek-vl"), any()))
|
||||||
.thenReturn(EnumSet.of(Modality.VISION));
|
.thenReturn(EnumSet.of(Modality.VISION));
|
||||||
|
|
||||||
@ -114,13 +125,14 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("4. Preferred provider unavailable → second preferred wins")
|
@DisplayName("4. Unconfigured first preferred is skipped → second preferred wins")
|
||||||
void firstPreferredUnavailableSecondWins() {
|
void firstPreferredUnavailableSecondWins() {
|
||||||
stubNoCapabilities();
|
stubNoCapabilities();
|
||||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek", "dashscope"));
|
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek", "dashscope"));
|
||||||
when(modelConfigService.getDefaultModelByProvider("deepseek")).thenReturn(null);
|
// deepseek has no usable credentials → must be skipped, not selected
|
||||||
when(modelConfigService.getDefaultModelByProvider("dashscope"))
|
// and then bounced to the global default.
|
||||||
.thenReturn(model("dashscope", "qwen-max"));
|
when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false);
|
||||||
|
stubConfiguredProvider("dashscope", model("dashscope", "qwen-max"));
|
||||||
|
|
||||||
ModelConfigEntity global = model("openai", "gpt-4o");
|
ModelConfigEntity global = model("openai", "gpt-4o");
|
||||||
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
||||||
@ -131,11 +143,11 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("5. All preferred unavailable → global default")
|
@DisplayName("5. All preferred unconfigured → global default")
|
||||||
void allPreferredUnavailableFallsBackToGlobal() {
|
void allPreferredUnavailableFallsBackToGlobal() {
|
||||||
stubNoCapabilities();
|
stubNoCapabilities();
|
||||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||||
when(modelConfigService.getDefaultModelByProvider("deepseek")).thenReturn(null);
|
when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false);
|
||||||
|
|
||||||
ModelConfigEntity global = model("openai", "gpt-4o");
|
ModelConfigEntity global = model("openai", "gpt-4o");
|
||||||
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
||||||
@ -167,8 +179,7 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
void preferredMissesCapabilityGlobalSatisfies() {
|
void preferredMissesCapabilityGlobalSatisfies() {
|
||||||
bindSkillRequiring("vision");
|
bindSkillRequiring("vision");
|
||||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||||
when(modelConfigService.getDefaultModelByProvider("deepseek"))
|
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
|
||||||
.thenReturn(model("deepseek", "deepseek-chat"));
|
|
||||||
when(capabilityService.resolve(eq("deepseek-chat"), any()))
|
when(capabilityService.resolve(eq("deepseek-chat"), any()))
|
||||||
.thenReturn(EnumSet.noneOf(Modality.class));
|
.thenReturn(EnumSet.noneOf(Modality.class));
|
||||||
|
|
||||||
@ -182,4 +193,22 @@ class ProviderRouterSelectPrimaryTest {
|
|||||||
assertEquals("openai", result.getProvider());
|
assertEquals("openai", result.getProvider());
|
||||||
assertEquals("gpt-4o", result.getModelName());
|
assertEquals("gpt-4o", result.getModelName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("9. Configured preferred provider without a system-default model still resolves")
|
||||||
|
void preferredResolvesViaPerProviderFallback() {
|
||||||
|
stubNoCapabilities();
|
||||||
|
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||||
|
// getPrimaryChatModelByProvider encapsulates the system-default →
|
||||||
|
// first-enabled-chat fallback, so a preferred provider that does not
|
||||||
|
// hold the single global default still contributes a primary model.
|
||||||
|
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
|
||||||
|
|
||||||
|
ModelConfigEntity global = model("volcengine-plan", "doubao-seed");
|
||||||
|
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("deepseek", result.getProvider());
|
||||||
|
assertEquals("deepseek-chat", result.getModelName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user