diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java index 3acb5654..a4172b93 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java @@ -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") 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 65abdd22..19180b53 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 @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index b6ea5a93..983cd49e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -183,6 +183,32 @@ public class ModelConfigService { .last("LIMIT 1")); } + /** + * Resolve a provider's primary chat model for routing. + * + *

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() + .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())) { 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 index 177e6b70..f373ba81 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/ProviderRouterSelectPrimaryTest.java @@ -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()); + } }