fix(llm): preferred provider now drives primary model selection — per-provider model resolution + unconfigured-provider skip

This commit is contained in:
matevip 2026-06-04 07:36:31 +08:00
parent b22462105c
commit 76c6504527
4 changed files with 78 additions and 15 deletions

View File

@ -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")

View File

@ -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;

View File

@ -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())) {

View File

@ -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());
}
}