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