mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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();
|
||||
}
|
||||
|
||||
// ==================== Provider Preferences (RFC-009 PR-3) ====================
|
||||
// ==================== Provider Preferences ====================
|
||||
|
||||
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
|
||||
@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.Modality;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
@ -45,6 +46,7 @@ public class ProviderRouter {
|
||||
private final AgentBindingResolver bindingService;
|
||||
private final ModelCapabilityService capabilityService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
|
||||
/**
|
||||
* Compute the union of capability requirements declared by the
|
||||
@ -249,9 +251,15 @@ public class ProviderRouter {
|
||||
private ModelConfigEntity pickProviderDefault(String providerId) {
|
||||
if (providerId == null || providerId.isBlank()) return null;
|
||||
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) {
|
||||
// getDefaultModelByProvider can return null or throw when
|
||||
// getPrimaryChatModelByProvider can return null or throw when
|
||||
// the provider has no enabled chat model; treat both as
|
||||
// "no candidate from this provider".
|
||||
return null;
|
||||
|
||||
@ -183,6 +183,32 @@ public class ModelConfigService {
|
||||
.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) {
|
||||
validateModel(entity, null);
|
||||
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.Modality;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
@ -30,6 +31,7 @@ class ProviderRouterSelectPrimaryTest {
|
||||
@Mock private AgentBindingResolver bindingService;
|
||||
@Mock private ModelCapabilityService capabilityService;
|
||||
@Mock private ModelConfigService modelConfigService;
|
||||
@Mock private ModelProviderService modelProviderService;
|
||||
|
||||
@InjectMocks private ProviderRouter router;
|
||||
|
||||
@ -63,6 +65,17 @@ class ProviderRouterSelectPrimaryTest {
|
||||
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 ----
|
||||
|
||||
@Test
|
||||
@ -70,8 +83,7 @@ class ProviderRouterSelectPrimaryTest {
|
||||
void preferredWinsWithoutCapabilities() {
|
||||
stubNoCapabilities();
|
||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||
when(modelConfigService.getDefaultModelByProvider("deepseek"))
|
||||
.thenReturn(model("deepseek", "deepseek-chat"));
|
||||
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
|
||||
|
||||
ModelConfigEntity global = model("openai", "gpt-4o");
|
||||
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
||||
@ -86,8 +98,7 @@ class ProviderRouterSelectPrimaryTest {
|
||||
void preferredSatisfyingCapabilityWins() {
|
||||
bindSkillRequiring("vision");
|
||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||
when(modelConfigService.getDefaultModelByProvider("deepseek"))
|
||||
.thenReturn(model("deepseek", "deepseek-vl"));
|
||||
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-vl"));
|
||||
when(capabilityService.resolve(eq("deepseek-vl"), any()))
|
||||
.thenReturn(EnumSet.of(Modality.VISION));
|
||||
|
||||
@ -114,13 +125,14 @@ class ProviderRouterSelectPrimaryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. Preferred provider unavailable → second preferred wins")
|
||||
@DisplayName("4. Unconfigured first preferred is skipped → 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"));
|
||||
// deepseek has no usable credentials → must be skipped, not selected
|
||||
// and then bounced to the global default.
|
||||
when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false);
|
||||
stubConfiguredProvider("dashscope", model("dashscope", "qwen-max"));
|
||||
|
||||
ModelConfigEntity global = model("openai", "gpt-4o");
|
||||
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
|
||||
@ -131,11 +143,11 @@ class ProviderRouterSelectPrimaryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. All preferred unavailable → global default")
|
||||
@DisplayName("5. All preferred unconfigured → global default")
|
||||
void allPreferredUnavailableFallsBackToGlobal() {
|
||||
stubNoCapabilities();
|
||||
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 result = router.selectPrimary(AGENT_ID, global);
|
||||
@ -167,8 +179,7 @@ class ProviderRouterSelectPrimaryTest {
|
||||
void preferredMissesCapabilityGlobalSatisfies() {
|
||||
bindSkillRequiring("vision");
|
||||
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
|
||||
when(modelConfigService.getDefaultModelByProvider("deepseek"))
|
||||
.thenReturn(model("deepseek", "deepseek-chat"));
|
||||
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
|
||||
when(capabilityService.resolve(eq("deepseek-chat"), any()))
|
||||
.thenReturn(EnumSet.noneOf(Modality.class));
|
||||
|
||||
@ -182,4 +193,22 @@ class ProviderRouterSelectPrimaryTest {
|
||||
assertEquals("openai", result.getProvider());
|
||||
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