feat(agent): per-employee model-chain preference (provider + model, repeatable provider)

Lets an employee pin an ordered fallback chain of (provider, model) entries; the same provider may appear multiple times with different models. Build-time dedup keys on exact (provider, model).
This commit is contained in:
倪程伟 2026-06-28 13:05:48 +08:00 committed by GitHub
parent e670bac3a8
commit 7be8f81353
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 520 additions and 182 deletions

View File

@ -39,6 +39,7 @@ import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
@ -1184,68 +1185,101 @@ public class AgentGraphBuilder {
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
// RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
// their declared order; everything else keeps the global priority order.
List<String> preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderIds(agentId);
if (!preferred.isEmpty()) {
providers = reorderByPreferences(providers, preferred);
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
}
// RFC-090 §9.2 调整 C second-pass reorder: lift providers
// that satisfy the bound-skill capability set (vision / video /
// audio) ahead of those that don't. Stable otherwise so the
// user-preferred order still wins among capable providers.
// RFC-090 §9.2 调整 C lift providers that satisfy the bound-skill
// capability set (vision / video / audio) ahead of those that don't.
// Run before planning so the non-preferred tail inherits this order;
// the explicit preferred-model head keeps the user's declared order.
try {
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
} catch (Exception e) {
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage());
}
// Preferred-model chain: explicit (provider, model) entries lead in the
// user's order the same provider may repeat with different models
// then every non-preferred provider follows with its default model.
List<ProviderModelRef> preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderModels(agentId);
List<String> globalProviderIds = providers.stream()
.map(ModelProviderEntity::getProviderId)
.toList();
List<ProviderModelRef> plan = planFallbackOrder(preferred, globalProviderIds);
if (!preferred.isEmpty()) {
log.debug("[LlmFailover] agent={} preferred-model chain={} -> plan={}", agentId, preferred, plan);
}
// Dedup by exact (provider, model) seeded with the primary so we never
// rebuild the primary call, but OTHER models of the primary provider are
// still legitimate fallback entries.
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
// Don't put the primary provider's row into the fallback chain same-instance
// skipping is also done in the runtime walker, but excluding here saves building
// a duplicate ChatModel at agent-build time.
if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) {
log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId);
continue;
}
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in
// NodeStreamingChatHelper re-checks pool membership per request, so a provider
// that re-enters the pool later still gets used (the graph is rebuilt on
Set<String> seen = new java.util.HashSet<>();
if (primaryProviderId != null && primaryModelName != null) {
seen.add(primaryProviderId + "::" + primaryModelName);
}
for (ProviderModelRef ref : plan) {
String pid = ref.providerId();
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime
// walker re-checks pool membership per request, so a provider that
// re-enters the pool later still gets used (graph rebuilt on
// ModelConfigChangedEvent).
if (providerPool != null && !providerPool.contains(p.getProviderId())) {
log.debug("[LlmFailover] skipping provider {} — not in available pool",
p.getProviderId());
if (providerPool != null && !providerPool.contains(pid)) {
log.debug("[LlmFailover] skipping provider {} — not in available pool", pid);
continue;
}
ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId());
ModelConfigEntity fallbackConfig = resolveChainModel(ref);
if (fallbackConfig == null) {
log.debug("[LlmFailover] skipping provider {} — no enabled chat model",
p.getProviderId());
log.debug("[LlmFailover] skipping {} — no usable chat model", pid);
continue;
}
if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) {
// Same model name picked for a different provider exact same call, skip.
String key = pid + "::" + fallbackConfig.getModelName();
if (!seen.add(key)) {
// Exact (provider, model) already queued or equal to the primary.
continue;
}
try {
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m));
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
p.getFallbackPriority());
chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m));
log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName());
} catch (Exception e) {
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
p.getProviderId(), e.getMessage());
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage());
}
}
return chain;
}
/**
* Resolve a planned chain entry to a concrete chat model. A pinned model
* ({@code modelId != null}) is used when it still exists and is enabled;
* otherwise we fall back to the provider's default chat model so a deleted
* or disabled pin keeps the provider in the chain.
*/
private ModelConfigEntity resolveChainModel(ProviderModelRef ref) {
if (ref.modelId() != null) {
try {
ModelConfigEntity m = modelConfigService.getModel(ref.modelId());
// Honour the pin only when it is a usable chat model that actually
// belongs to this entry's provider. The FallbackEntry is keyed by
// ref.providerId() for cooldown/pool, so a model from a different
// provider would mis-key the chain; an embedding model would never
// serve as a chat fallback. Either case falls back to the
// provider's default chat model.
if (m != null && Boolean.TRUE.equals(m.getEnabled())
&& ref.providerId().equals(m.getProvider())
&& (m.getModelType() == null || "chat".equals(m.getModelType()))) {
return m;
}
log.info("[LlmFailover] pinned model {} for provider {} not usable "
+ "(disabled / wrong provider / non-chat), using provider default",
ref.modelId(), ref.providerId());
} catch (Exception e) {
log.info("[LlmFailover] pinned model {} for provider {} unresolved ({}), using provider default",
ref.modelId(), ref.providerId(), e.getMessage());
}
}
return pickFallbackModel(ref.providerId());
}
/**
* Pick a chat model to use as a fallback for the given provider:
* <ol>
@ -1275,33 +1309,40 @@ public class AgentGraphBuilder {
}
/**
* Reorder a provider list by an agent's preference list. Listed provider
* ids come first in their preference order; any provider not in the
* preference list keeps its original position relative to other unlisted
* providers (stable partition). Preference entries that don't match any
* actual provider are silently dropped.
* Plan the fallback order as a list of (provider, model) refs.
*
* <p>Head: the agent's explicit preference entries in declared order,
* model-granular the same provider may appear more than once with
* different models. Exact (provider, model) duplicates are dropped.
*
* <p>Tail: every provider not named in the preferences, in the supplied
* global order, each using its default model ({@code modelId == null}).
*
* <p>Preference entries with a blank provider id are ignored. Package-private
* for unit testing see {@code AgentGraphBuilderPreferenceTest}.
*/
/** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
static List<ModelProviderEntity> reorderByPreferences(List<ModelProviderEntity> providers,
List<String> preferredOrder) {
Map<String, ModelProviderEntity> byId = new java.util.LinkedHashMap<>();
for (ModelProviderEntity p : providers) {
byId.put(p.getProviderId(), p);
}
List<ModelProviderEntity> reordered = new ArrayList<>(providers.size());
Set<String> placed = new java.util.HashSet<>();
for (String prefId : preferredOrder) {
ModelProviderEntity p = byId.get(prefId);
if (p != null && placed.add(prefId)) {
reordered.add(p);
static List<ProviderModelRef> planFallbackOrder(List<ProviderModelRef> preferred,
List<String> globalProviderIds) {
List<ProviderModelRef> plan = new ArrayList<>();
Set<String> headEntryKeys = new java.util.HashSet<>();
Set<String> headProviderIds = new java.util.HashSet<>();
if (preferred != null) {
for (ProviderModelRef ref : preferred) {
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId());
if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup
plan.add(ref);
headProviderIds.add(ref.providerId());
}
}
for (ModelProviderEntity p : providers) {
if (placed.add(p.getProviderId())) {
reordered.add(p);
if (globalProviderIds != null) {
for (String pid : globalProviderIds) {
if (pid == null || pid.isBlank()) continue;
if (headProviderIds.contains(pid)) continue; // already led by an explicit entry
plan.add(new ProviderModelRef(pid, null));
}
}
return reordered;
return plan;
}
/**

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
@ -122,18 +123,18 @@ public class AgentBindingController {
return R.ok(bindingService.listProviderPreferences(agentId));
}
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
@Operation(summary = "批量设置 Agent 的偏好模型链(供应商 + 模型,替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R<Void> setProviderPreferences(
@PathVariable Long agentId,
@RequestBody List<String> providerIds,
@RequestBody List<ProviderModelRef> preferences,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setProviderPreferences(agentId, providerIds);
bindingService.setProviderModelPreferences(agentId, preferences);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
"providers=" + providerIds.size(), null);
"entries=" + (preferences == null ? 0 : preferences.size()), null);
return R.ok();
}

View File

@ -29,6 +29,16 @@ public class AgentProviderPreference {
/** Provider id (matches {@code mate_model_provider.provider_id}). */
private String providerId;
/**
* Specific chat model to pin for this entry (matches
* {@code mate_model_config.id}). {@code null} means "use the provider's
* default chat model" — backward compatible with provider-only
* preferences. With this column the same {@code providerId} may appear
* in multiple rows, each pinning a different model, forming a per-agent
* preferred-model chain.
*/
private Long modelId;
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
private Integer sortOrder;

View File

@ -18,6 +18,7 @@ import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.lifecycle.BlockedByBindingRow;
@ -935,30 +936,33 @@ public class AgentBindingService implements AgentBindingResolver {
* fallback chain order per agent.</p>
*/
@Override
public List<String> getPreferredProviderIds(Long agentId) {
public List<ProviderModelRef> getPreferredProviderModels(Long agentId) {
if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream()
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
.map(AgentProviderPreference::getProviderId)
.map(p -> new ProviderModelRef(p.getProviderId(), p.getModelId()))
.collect(Collectors.toList());
}
/**
* Replace the full preference list for an agent. {@code providerIds}
* is the new ordered preference (index 0 = highest preference).
* Empty / null list clears all preferences for the agent.
* Replace the full preference list for an agent with (provider, model)
* entries. {@code refs} is the new ordered preference (index 0 = highest);
* a {@code modelId} of {@code null} pins the provider's default model. The
* same provider may appear multiple times with different models, forming a
* preferred-model chain. Empty / null list clears all preferences.
*/
public void setProviderPreferences(Long agentId, List<String> providerIds) {
public void setProviderModelPreferences(Long agentId, List<ProviderModelRef> refs) {
providerPreferenceMapper.delete(
new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId));
if (providerIds == null) return;
if (refs == null) return;
int order = 0;
for (String providerId : providerIds) {
if (providerId == null || providerId.isBlank()) continue;
for (ProviderModelRef ref : refs) {
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
AgentProviderPreference row = new AgentProviderPreference();
row.setAgentId(agentId);
row.setProviderId(providerId.trim());
row.setProviderId(ref.providerId().trim());
row.setModelId(ref.modelId());
row.setSortOrder(order++);
row.setEnabled(true);
providerPreferenceMapper.insert(row);

View File

@ -21,9 +21,13 @@ public interface AgentBindingResolver {
Set<Long> getBoundSkillIds(Long agentId);
/**
* Provider ids the agent prefers, in priority order; empty when none.
* Ordered preferred-model chain for the agent: each entry is a provider
* plus an optional pinned model ({@code modelId == null} = the provider's
* default chat model). The same provider may repeat with different models,
* so an agent can express a chain like {@code A/modelX A/modelY
* B/modelZ}. Empty when the agent has no preferences.
*/
List<String> getPreferredProviderIds(Long agentId);
List<ProviderModelRef> getPreferredProviderModels(Long agentId);
/**
* Wiki knowledge-base ids bound to the agent, or {@code null} when the

View File

@ -0,0 +1,18 @@
package vip.mate.llm.routing;
/**
* One entry in an agent's preferred-model chain: a provider plus an optional
* specific chat model.
*
* <p>{@code modelId == null} means "use the provider's default chat model"
* backward compatible with provider-only preferences. The same
* {@code providerId} may appear in multiple entries, each pinning a different
* model, so an agent can express a chain like {@code A/modelX A/modelY
* B/modelZ}.
*
* @param providerId provider id (matches {@code mate_model_provider.provider_id})
* @param modelId pinned model id (matches {@code mate_model_config.id}), or
* {@code null} for the provider's default chat model
*/
public record ProviderModelRef(String providerId, Long modelId) {
}

View File

@ -189,14 +189,14 @@ public class ProviderRouter {
public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) {
if (agentId == null) return globalDefault;
List<String> preferred = bindingService.getPreferredProviderIds(agentId);
List<ProviderModelRef> preferred = bindingService.getPreferredProviderModels(agentId);
Set<Modality> requiredModalities = resolveRequiredModalities(agentId);
// Pass 1: capability-satisfying providers (preferred first, global fallback)
// Pass 1: capability-satisfying entries (preferred first, global fallback)
if (requiredModalities != null) {
// 1a. preferred providers satisfying capabilities
for (String providerId : preferred) {
ModelConfigEntity candidate = pickProviderDefault(providerId);
// 1a. preferred (provider, model) entries satisfying capabilities
for (ProviderModelRef ref : preferred) {
ModelConfigEntity candidate = pickPreferredModel(ref);
if (candidate == null) continue;
if (satisfies(candidate, requiredModalities)) {
log.info("[ProviderRouter] agent={} primary={}/{} (preferred, satisfies {})",
@ -213,9 +213,9 @@ public class ProviderRouter {
}
// Pass 2: unconstrained (capability ignored last resort)
// 2a. any available preferred provider
for (String providerId : preferred) {
ModelConfigEntity candidate = pickProviderDefault(providerId);
// 2a. any available preferred entry
for (ProviderModelRef ref : preferred) {
ModelConfigEntity candidate = pickPreferredModel(ref);
if (candidate == null) continue;
log.info("[ProviderRouter] agent={} primary={}/{} (preferred, unconstrained)",
agentId, candidate.getProvider(), candidate.getModelName());
@ -231,6 +231,35 @@ public class ProviderRouter {
return null;
}
/**
* Resolve a preference entry to a usable primary model. A pinned model
* ({@code modelId != null}) is honoured when its provider is configured and
* the model is enabled; otherwise we fall back to the provider's default
* chat model so a deleted/disabled pin does not silently drop the provider.
*/
private ModelConfigEntity pickPreferredModel(ProviderModelRef ref) {
if (ref == null) return null;
if (ref.modelId() == null) return pickProviderDefault(ref.providerId());
if (ref.providerId() == null || ref.providerId().isBlank()) return null;
try {
if (!modelProviderService.isProviderConfigured(ref.providerId())) return null;
ModelConfigEntity m = modelConfigService.getModel(ref.modelId());
// Honour the pin only when it is a usable chat model that actually
// belongs to this entry's provider; otherwise fall back to the
// provider's default chat model.
if (m != null && Boolean.TRUE.equals(m.getEnabled())
&& ref.providerId().equals(m.getProvider())
&& (m.getModelType() == null || "chat".equals(m.getModelType()))) {
return m;
}
} catch (Exception e) {
// getModel throws when the pinned model id no longer exists.
log.info("[ProviderRouter] pinned model {} for provider {} unresolved ({}), using provider default",
ref.modelId(), ref.providerId(), e.getMessage());
}
return pickProviderDefault(ref.providerId());
}
/** Returns null when no capabilities are required (skips Pass 1). */
private Set<Modality> resolveRequiredModalities(Long agentId) {
Set<String> needs = aggregateModelNeeds(agentId);

View File

@ -0,0 +1,21 @@
-- Per-agent preferred *model* chain (provider + model).
--
-- Extends mate_agent_provider_preference from provider-level to
-- (provider, model)-level: a preference entry may now pin a specific chat
-- model, and the SAME provider may appear multiple times with different
-- models (e.g. A/modelX -> A/modelY -> B/modelZ).
--
-- model_id NULL = use the provider's default chat model (fully backward
-- compatible with pre-existing provider-only rows).
-- model_id <id> = matches mate_model_config.id — pin that exact model.
--
-- The unique key moves from (agent_id, provider_id) to
-- (agent_id, provider_id, model_id) so the same provider can repeat. Note
-- NULLs are treated as distinct in a unique index, so duplicate
-- provider-default rows are not DB-enforced; the service replaces the whole
-- list on save and the UI prevents that, so this is intentional.
ALTER TABLE mate_agent_provider_preference ADD COLUMN IF NOT EXISTS model_id BIGINT;
DROP INDEX IF EXISTS uk_agent_provider;
CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider_model
ON mate_agent_provider_preference(agent_id, provider_id, model_id);

View File

@ -0,0 +1,21 @@
-- Per-agent preferred *model* chain (provider + model).
--
-- Extends mate_agent_provider_preference from provider-level to
-- (provider, model)-level: a preference entry may now pin a specific chat
-- model, and the SAME provider may appear multiple times with different
-- models (e.g. A/modelX -> A/modelY -> B/modelZ).
--
-- model_id NULL = use the provider's default chat model (fully backward
-- compatible with pre-existing provider-only rows).
-- model_id <id> = matches mate_model_config.id — pin that exact model.
--
-- The unique key moves from (agent_id, provider_id) to
-- (agent_id, provider_id, model_id) so the same provider can repeat. Note
-- NULLs are treated as distinct in a unique index, so duplicate
-- provider-default rows are not DB-enforced; the service replaces the whole
-- list on save and the UI prevents that, so this is intentional.
ALTER TABLE mate_agent_provider_preference ADD COLUMN IF NOT EXISTS model_id BIGINT;
DROP INDEX IF EXISTS uk_agent_provider;
CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_provider_model
ON mate_agent_provider_preference (agent_id, provider_id, model_id);

View File

@ -0,0 +1,46 @@
-- Per-agent preferred *model* chain (provider + model).
--
-- Extends mate_agent_provider_preference from provider-level to
-- (provider, model)-level: a preference entry may now pin a specific chat
-- model, and the SAME provider may appear multiple times with different
-- models (e.g. A/modelX -> A/modelY -> B/modelZ).
--
-- model_id NULL = use the provider's default chat model (fully backward
-- compatible with pre-existing provider-only rows).
-- model_id <id> = matches mate_model_config.id — pin that exact model.
--
-- The unique key moves from (agent_id, provider_id) to
-- (agent_id, provider_id, model_id) so the same provider can repeat. Note
-- NULLs are treated as distinct in a unique index, so duplicate
-- provider-default rows are not DB-enforced; the service replaces the whole
-- list on save and the UI prevents that, so this is intentional.
-- All three DDL statements below are wrapped in INFORMATION_SCHEMA guards
-- so the migration is idempotent: a mid-migration failure followed by a
-- Flyway repair + re-run will not choke on "column already exists" or
-- "index not found". MySQL 8.0 lacks native ADD COLUMN IF NOT EXISTS, so
-- the project convention is PREPARE/EXECUTE (see V156, V137).
-- 1) ADD COLUMN model_id (idempotent)
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_provider_preference' AND COLUMN_NAME = 'model_id');
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_agent_provider_preference ADD COLUMN model_id BIGINT NULL',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2) DROP old unique index uk_agent_provider (only if it exists)
SET @idx_old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_provider_preference' AND INDEX_NAME = 'uk_agent_provider');
SET @ddl := IF(@idx_old > 0,
'ALTER TABLE mate_agent_provider_preference DROP INDEX uk_agent_provider',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3) CREATE new unique index uk_agent_provider_model (only if it doesn't exist)
SET @idx_new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_provider_preference' AND INDEX_NAME = 'uk_agent_provider_model');
SET @ddl := IF(@idx_new = 0,
'CREATE UNIQUE INDEX uk_agent_provider_model ON mate_agent_provider_preference(agent_id, provider_id, model_id)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -2,77 +2,97 @@ package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.routing.ProviderModelRef;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* RFC-009 PR-3 verifies the agent-preference reorder used by
* {@link AgentGraphBuilder#buildFallbackChain}: listed providers move to the
* front in their declared order; unlisted providers keep their original
* relative order; missing/duplicate preferences are ignored gracefully.
* Verifies the preferred-model chain planning used by
* {@link AgentGraphBuilder#buildFallbackChain}: explicit (provider, model)
* entries lead in declared order (same provider may repeat with different
* models), then every non-preferred provider follows in the global order with
* its default model. Exact (provider, model) duplicates and blank ids are
* dropped; tail entries never repeat a provider already led by an explicit
* entry.
*/
class AgentGraphBuilderPreferenceTest {
private static ModelProviderEntity p(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
return p;
private static ProviderModelRef ref(String providerId, Long modelId) {
return new ProviderModelRef(providerId, modelId);
}
private static List<String> ids(List<ModelProviderEntity> ps) {
return ps.stream().map(ModelProviderEntity::getProviderId).toList();
private static List<String> keys(List<ProviderModelRef> plan) {
return plan.stream()
.map(r -> r.providerId() + "/" + (r.modelId() == null ? "default" : r.modelId()))
.toList();
}
@Test
@DisplayName("Empty preferences: original order preserved")
@DisplayName("No preferences: tail = global order, all default models")
void noPreferences() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of());
assertEquals(List.of("openai", "anthropic", "dashscope"), ids(out));
var out = AgentGraphBuilder.planFallbackOrder(
List.of(), List.of("openai", "anthropic", "dashscope"));
assertEquals(List.of("openai/default", "anthropic/default", "dashscope/default"), keys(out));
}
@Test
@DisplayName("Single preference: preferred provider moves to front, rest follow original order")
void singlePreferenceFront() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("dashscope"));
assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out));
@DisplayName("Single provider-default preference moves to front, tail drops it")
void singleProviderDefaultFront() {
var out = AgentGraphBuilder.planFallbackOrder(
List.of(ref("dashscope", null)),
List.of("openai", "anthropic", "dashscope"));
assertEquals(List.of("dashscope/default", "openai/default", "anthropic/default"), keys(out));
}
@Test
@DisplayName("Multiple preferences: preferred order matches declaration, rest stable")
void multiplePreferencesOrder() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"), p("kimi"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("kimi", "anthropic"));
// kimi anthropic (rest in original order: openai, dashscope)
assertEquals(List.of("kimi", "anthropic", "openai", "dashscope"), ids(out));
@DisplayName("Same provider repeated with different models — both kept, in order")
void sameProviderMultipleModels() {
var out = AgentGraphBuilder.planFallbackOrder(
List.of(ref("openai", 1L), ref("openai", 2L), ref("anthropic", 3L)),
List.of("openai", "anthropic", "dashscope"));
// explicit head in order, then only the un-named provider (dashscope) trails
assertEquals(
List.of("openai/1", "openai/2", "anthropic/3", "dashscope/default"),
keys(out));
}
@Test
@DisplayName("Preference references unknown provider: silently skipped")
void preferenceReferencesUnknown() {
var input = List.of(p("openai"), p("anthropic"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("ghost", "anthropic"));
assertEquals(List.of("anthropic", "openai"), ids(out));
@DisplayName("Exact (provider, model) duplicate is dropped")
void exactDuplicateDropped() {
var out = AgentGraphBuilder.planFallbackOrder(
List.of(ref("openai", 1L), ref("openai", 1L)),
List.of("openai", "anthropic"));
assertEquals(List.of("openai/1", "anthropic/default"), keys(out));
}
@Test
@DisplayName("Duplicate preferences: each provider appears at most once")
void duplicatePreferencesDeduped() {
var input = List.of(p("openai"), p("anthropic"));
var out = AgentGraphBuilder.reorderByPreferences(input, List.of("openai", "openai", "anthropic"));
assertEquals(List.of("openai", "anthropic"), ids(out));
@DisplayName("Provider pinned by id is not re-added as a default tail entry")
void pinnedProviderExcludedFromTail() {
var out = AgentGraphBuilder.planFallbackOrder(
List.of(ref("openai", 1L)),
List.of("openai", "anthropic"));
// openai already led explicitly no extra openai/default in the tail
assertEquals(List.of("openai/1", "anthropic/default"), keys(out));
}
@Test
@DisplayName("All providers preferred: input pure-reordered, no drops")
void allProvidersPreferred() {
var input = List.of(p("openai"), p("anthropic"), p("dashscope"));
var out = AgentGraphBuilder.reorderByPreferences(input,
List.of("dashscope", "openai", "anthropic"));
assertEquals(List.of("dashscope", "openai", "anthropic"), ids(out));
@DisplayName("Blank / null provider ids are ignored")
void blankIdsIgnored() {
var out = AgentGraphBuilder.planFallbackOrder(
List.of(ref("", 1L), ref(null, 2L), ref("openai", 3L)),
List.of("openai", "anthropic"));
assertEquals(List.of("openai/3", "anthropic/default"), keys(out));
}
@Test
@DisplayName("Preference for a provider absent from the global pool is still honoured")
void preferenceForUnknownProvider() {
var out = AgentGraphBuilder.planFallbackOrder(
List.of(ref("ghost", 9L)),
List.of("openai", "anthropic"));
// ghost leads (pool gating happens later in buildFallbackChain), tail follows
assertEquals(List.of("ghost/9", "openai/default", "anthropic/default"), keys(out));
}
}

View File

@ -46,6 +46,12 @@ class ProviderRouterSelectPrimaryTest {
return m;
}
private static ModelConfigEntity model(String provider, String name, boolean enabled) {
ModelConfigEntity m = model(provider, name);
m.setEnabled(enabled);
return m;
}
private void stubNoCapabilities() {
when(bindingService.getBoundSkillIds(AGENT_ID)).thenReturn(Set.of());
}
@ -82,7 +88,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("1. Preferred provider wins when no capability requirements")
void preferredWinsWithoutCapabilities() {
stubNoCapabilities();
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null)));
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
ModelConfigEntity global = model("openai", "gpt-4o");
@ -97,7 +103,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("2. Preferred provider satisfying the required capability wins in pass 1")
void preferredSatisfyingCapabilityWins() {
bindSkillRequiring("vision");
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null)));
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-vl"));
when(capabilityService.resolve(eq("deepseek-vl"), any()))
.thenReturn(EnumSet.of(Modality.VISION));
@ -114,7 +120,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("3. No preferred providers → global default")
void noPreferredFallsBackToGlobal() {
stubNoCapabilities();
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of());
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of());
ModelConfigEntity global = model("openai", "gpt-4o");
ModelConfigEntity result = router.selectPrimary(AGENT_ID, global);
@ -128,7 +134,8 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("4. Unconfigured first preferred is skipped → second preferred wins")
void firstPreferredUnavailableSecondWins() {
stubNoCapabilities();
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek", "dashscope"));
when(bindingService.getPreferredProviderModels(AGENT_ID))
.thenReturn(List.of(new ProviderModelRef("deepseek", null), new ProviderModelRef("dashscope", null)));
// deepseek has no usable credentials must be skipped, not selected
// and then bounced to the global default.
when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false);
@ -146,7 +153,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("5. All preferred unconfigured → global default")
void allPreferredUnavailableFallsBackToGlobal() {
stubNoCapabilities();
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null)));
when(modelProviderService.isProviderConfigured("deepseek")).thenReturn(false);
ModelConfigEntity global = model("openai", "gpt-4o");
@ -168,7 +175,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("7. Both preferred and global null → returns null")
void allNullReturnsNull() {
stubNoCapabilities();
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of());
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of());
ModelConfigEntity result = router.selectPrimary(AGENT_ID, null);
assertNull(result);
@ -178,7 +185,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("8. Preferred misses required capability but global satisfies → global wins in pass 1")
void preferredMissesCapabilityGlobalSatisfies() {
bindSkillRequiring("vision");
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null)));
stubConfiguredProvider("deepseek", model("deepseek", "deepseek-chat"));
when(capabilityService.resolve(eq("deepseek-chat"), any()))
.thenReturn(EnumSet.noneOf(Modality.class));
@ -198,7 +205,7 @@ class ProviderRouterSelectPrimaryTest {
@DisplayName("9. Configured preferred provider without a system-default model still resolves")
void preferredResolvesViaPerProviderFallback() {
stubNoCapabilities();
when(bindingService.getPreferredProviderIds(AGENT_ID)).thenReturn(List.of("deepseek"));
when(bindingService.getPreferredProviderModels(AGENT_ID)).thenReturn(List.of(new ProviderModelRef("deepseek", null)));
// 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.
@ -211,4 +218,94 @@ class ProviderRouterSelectPrimaryTest {
assertEquals("deepseek", result.getProvider());
assertEquals("deepseek-chat", result.getModelName());
}
@Test
@DisplayName("10. Pinned model on a configured provider is honoured verbatim")
void pinnedModelWins() {
stubNoCapabilities();
when(bindingService.getPreferredProviderModels(AGENT_ID))
.thenReturn(List.of(new ProviderModelRef("dashscope", 77L)));
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelConfigService.getModel(77L)).thenReturn(model("dashscope", "qwen-vl-max", true));
ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o"));
assertNotNull(result);
assertEquals("dashscope", result.getProvider());
assertEquals("qwen-vl-max", result.getModelName());
// provider-default lookup must NOT be consulted when a live pin resolves
verify(modelConfigService, never()).getPrimaryChatModelByProvider("dashscope");
}
@Test
@DisplayName("11. Same provider pinned to two models: first entry wins as primary")
void sameProviderTwoModelsFirstWins() {
stubNoCapabilities();
when(bindingService.getPreferredProviderModels(AGENT_ID))
.thenReturn(List.of(new ProviderModelRef("dashscope", 1L), new ProviderModelRef("dashscope", 2L)));
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelConfigService.getModel(1L)).thenReturn(model("dashscope", "qwen-max", true));
ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o"));
assertNotNull(result);
assertEquals("qwen-max", result.getModelName());
}
@Test
@DisplayName("12. Disabled pinned model falls back to the provider's default")
void pinnedModelDisabledFallsBackToProviderDefault() {
stubNoCapabilities();
when(bindingService.getPreferredProviderModels(AGENT_ID))
.thenReturn(List.of(new ProviderModelRef("dashscope", 99L)));
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
when(modelConfigService.getModel(99L)).thenReturn(model("dashscope", "qwen-old", false));
when(modelConfigService.getPrimaryChatModelByProvider("dashscope"))
.thenReturn(model("dashscope", "qwen-max"));
ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o"));
assertNotNull(result);
assertEquals("dashscope", result.getProvider());
assertEquals("qwen-max", result.getModelName());
}
@Test
@DisplayName("13. Pinned model that belongs to a different provider falls back to the provider default")
void pinnedModelWrongProviderFallsBack() {
stubNoCapabilities();
when(bindingService.getPreferredProviderModels(AGENT_ID))
.thenReturn(List.of(new ProviderModelRef("dashscope", 88L)));
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
// The pinned id resolves to a model owned by ANOTHER provider must not
// be used under dashscope's cooldown/credentials.
when(modelConfigService.getModel(88L)).thenReturn(model("openai", "gpt-4o", true));
when(modelConfigService.getPrimaryChatModelByProvider("dashscope"))
.thenReturn(model("dashscope", "qwen-max"));
ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("anthropic", "claude"));
assertNotNull(result);
assertEquals("dashscope", result.getProvider());
assertEquals("qwen-max", result.getModelName());
}
@Test
@DisplayName("14. Pinned non-chat (embedding) model falls back to the provider default")
void pinnedNonChatModelFallsBack() {
stubNoCapabilities();
when(bindingService.getPreferredProviderModels(AGENT_ID))
.thenReturn(List.of(new ProviderModelRef("dashscope", 55L)));
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
ModelConfigEntity embedding = model("dashscope", "text-embedding-v3", true);
embedding.setModelType("embedding");
when(modelConfigService.getModel(55L)).thenReturn(embedding);
when(modelConfigService.getPrimaryChatModelByProvider("dashscope"))
.thenReturn(model("dashscope", "qwen-max"));
ModelConfigEntity result = router.selectPrimary(AGENT_ID, model("openai", "gpt-4o"));
assertNotNull(result);
assertEquals("qwen-max", result.getModelName());
}
}

View File

@ -1031,11 +1031,16 @@ export const agentBindingApi = {
unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`),
listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`),
setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames),
// RFC-009 PR-3: per-agent provider preference order. Empty list = use global chain order.
// Per-agent preferred-model chain (provider + model). Empty list = use the
// global chain order. modelId null = the provider's default model; the same
// provider may appear multiple times with different models. modelId is a
// string to preserve Snowflake precision.
listProviderPreferences: (agentId: string | number) =>
http.get(`/agents/${agentId}/provider-preferences`),
setProviderPreferences: (agentId: string | number, providerIds: string[]) =>
http.put(`/agents/${agentId}/provider-preferences`, providerIds),
setProviderPreferences: (
agentId: string | number,
preferences: Array<{ providerId: string; modelId: string | null }>,
) => http.put(`/agents/${agentId}/provider-preferences`, preferences),
// Per-agent knowledge base access scope. Empty array = unrestricted
// (agent can reach every KB in its workspace). IDs are kept as strings
// for the Snowflake-precision contract.

View File

@ -1397,13 +1397,14 @@ export default {
toolUnavailableTooltip: 'This tool\'s name conflicts with another tool on the same server and cannot be bound ({reason}). Rename the upstream tool to resolve.',
toolOrphanGroup: 'Bound but no longer available',
toolOrphanDescription: 'This tool was previously bound but is no longer in the available catalog (its MCP server may have been removed, or the tool was retired upstream). Uncheck and save to clean up the leftover binding.',
providersHint: 'Preferred provider order for this agent (lower index tried first). Leave empty to use the global available-pool order. Cooling-down or pool-removed providers are still skipped automatically.',
providersAddHint: 'Click a provider below to add it to the preference list:',
providersHint: 'Preferred provider + model chain for this agent (lower index tried first). Each entry may pin a specific model, and the same provider may be added more than once with different models. Leave empty to use the global available-pool order. Cooling-down or pool-removed providers are still skipped automatically.',
providersAddHint: 'Click a provider below to add an entry (the same provider may be added multiple times, each with its own model):',
providerDefaultModel: 'Provider default model',
noSkills: 'No skills available',
noTools: 'No tools available',
noMatchingSkills: 'No matching skills',
noMatchingTools: 'No matching tools',
noProviderPreferences: 'No preferences set — the agent uses the global fallback chain order.',
noProviderPreferences: 'No preferred-model chain set — the agent uses the global fallback chain order.',
wikiKicker: 'Knowledge Base Access',
wikiTagline: 'Limit which knowledge bases this agent can reach, so it never reads content outside its scope.',
wikiHint: 'Tick the knowledge bases this agent may access; mark one as the default (used by wiki tools when no kbId/kbName is given).',

View File

@ -1272,13 +1272,14 @@ export default {
toolUnavailableTooltip: '此工具的命名与同服务下的另一个工具冲突,无法绑定({reason})。请在上游 MCP 服务中重命名后重试。',
toolOrphanGroup: '已绑定但当前不可用',
toolOrphanDescription: '此工具在以前绑定过,但已不在当前可用工具列表中(如所属 MCP 服务被删除或工具被上游下线)。取消勾选并保存可清理掉这条遗留绑定。',
providersHint: '此智能体优先使用的提供商顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。提供商进入冷却或被移出池时仍会被自动跳过。',
providersAddHint: '点击下方提供商加入偏好列表:',
providersHint: '此智能体优先使用的「供应商 + 模型」链(数字越小越先尝试)。每条可指定具体模型,同一供应商可重复添加并分别选不同模型;留空则按全局可用池顺序回退。供应商进入冷却或被移出池时仍会被自动跳过。',
providersAddHint: '点击下方供应商添加一条偏好(同一供应商可多次添加,分别选模型):',
providerDefaultModel: '供应商默认模型',
noSkills: '暂无可用技能',
noTools: '暂无可用工具',
noMatchingSkills: '没有匹配的技能',
noMatchingTools: '没有匹配的工具',
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
noProviderPreferences: '尚未配置偏好模型链,将按全局回退链顺序使用。',
wikiKicker: '知识库访问范围',
wikiTagline: '限定此智能体可访问的知识库,防止它读取与业务无关的内容。',
wikiHint: '勾选此智能体允许访问的知识库;可将其中一个设为默认(未指定 kbId/kbName 时优先使用)。',

View File

@ -260,7 +260,7 @@
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'providers' }" @click="modalTab = 'providers'">
{{ t('agents.tabs.providers', 'Providers') }}
<span v-if="selectedProviderIds.length" class="tab-badge">{{ selectedProviderIds.length }}</span>
<span v-if="selectedProviderPrefs.length" class="tab-badge">{{ selectedProviderPrefs.length }}</span>
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'wiki' }" @click="modalTab = 'wiki'">
{{ t('agents.tabs.wiki', 'Wiki') }}
@ -593,34 +593,39 @@
</details>
</div>
<!-- Providers Tab (RFC-009 PR-3) -->
<!-- Providers Tab preferred-model chain (provider + model) -->
<div v-if="modalTab === 'providers'" class="binding-tab">
<p class="binding-hint">{{ t('agents.binding.providersHint') }}</p>
<!-- Picked: ordered list with up/down/remove controls -->
<div v-if="selectedProviderIds.length" class="provider-pref-list">
<!-- Picked: ordered (provider, model) entries with up/down/remove -->
<div v-if="selectedProviderPrefs.length" class="provider-pref-list">
<div
v-for="(pid, idx) in selectedProviderIds"
:key="pid"
v-for="(pref, idx) in selectedProviderPrefs"
:key="idx"
class="provider-pref-item"
>
<span class="provider-pref-rank">{{ idx + 1 }}</span>
<span class="provider-pref-name">{{ providerNameById(pid) }}</span>
<span class="provider-pref-id">{{ pid }}</span>
<button class="provider-pref-btn" :disabled="idx === 0" @click="moveProvider(idx, -1)"></button>
<button class="provider-pref-btn" :disabled="idx === selectedProviderIds.length - 1" @click="moveProvider(idx, 1)"></button>
<button class="provider-pref-btn danger" @click="removeProvider(idx)"></button>
<span class="provider-pref-name">{{ providerNameById(pref.providerId) }}</span>
<select class="provider-pref-model" v-model="pref.modelId">
<option :value="null">{{ t('agents.binding.providerDefaultModel') }}</option>
<option v-for="m in modelsForProvider(pref.providerId)" :key="m.id" :value="m.id">
{{ m.modelName }}
</option>
</select>
<button class="provider-pref-btn" :disabled="idx === 0" @click="moveProviderEntry(idx, -1)"></button>
<button class="provider-pref-btn" :disabled="idx === selectedProviderPrefs.length - 1" @click="moveProviderEntry(idx, 1)"></button>
<button class="provider-pref-btn danger" @click="removeProviderEntry(idx)"></button>
</div>
</div>
<div v-else class="binding-empty">{{ t('agents.binding.noProviderPreferences') }}</div>
<!-- Unpicked: click to append -->
<div v-if="unpickedProviders.length" class="provider-pref-pool">
<!-- Pool: click to append an entry (same provider may repeat) -->
<div v-if="availableProviders.length" class="provider-pref-pool">
<p class="binding-hint" style="margin-top: 14px">{{ t('agents.binding.providersAddHint') }}</p>
<button
v-for="p in unpickedProviders"
v-for="p in availableProviders"
:key="p.id"
class="provider-pref-add-btn"
@click="addProvider(p.id)"
@click="addProviderEntry(p.id)"
>+ {{ p.name }}</button>
</div>
</div>
@ -936,12 +941,16 @@ function setPrimaryKb(id: string | number) {
}
selectedKBId.value = sid
}
// RFC-009 PR-3: per-agent provider preference order
// Per-agent preferred-model chain. Each entry is a provider plus an optional
// pinned model (modelId null = the provider's default model). The same provider
// may appear more than once with different models. modelId is a string to keep
// Snowflake precision (Long is serialised as a string by the backend).
const availableProviders = ref<{ id: string; name: string }[]>([])
const selectedProviderIds = ref<string[]>([])
const selectedProviderPrefs = ref<Array<{ providerId: string; modelId: string | null }>>([])
// RFC-03 Lane G1: per-Agent model override picker populated from the
// global enabled-models list, blank value means "fall back to default".
const availableModels = ref<Array<{ id: number; name: string; provider: string; modelName: string }>>([])
// id is a string (Snowflake serialised as string).
const availableModels = ref<Array<{ id: string; name: string; provider: string; modelName: string }>>([])
// Template selector state
const showTemplateSelector = ref(false)
@ -1225,36 +1234,37 @@ function openBlankCreateModal() {
toolBindingSearch.value = ''
selectedSkillIds.value = []
selectedToolNames.value = []
selectedProviderIds.value = []
selectedProviderPrefs.value = []
availableKBs.value = []
selectedKBId.value = null
selectedKbIds.value = []
showModal.value = true
}
// RFC-009 PR-3: provider preference helpers
const unpickedProviders = computed(() =>
availableProviders.value.filter(p => !selectedProviderIds.value.includes(p.id))
)
// Preferred-model chain helpers
function providerNameById(id: string): string {
return availableProviders.value.find(p => p.id === id)?.name || id
}
function addProvider(id: string) {
if (!selectedProviderIds.value.includes(id)) {
selectedProviderIds.value.push(id)
}
// Enabled models offered by a given provider, for that entry's model dropdown.
function modelsForProvider(providerId: string) {
return availableModels.value.filter(m => m.provider === providerId)
}
function removeProvider(idx: number) {
selectedProviderIds.value.splice(idx, 1)
// Append a new chain entry (defaults to the provider's default model). The same
// provider may appear more than once, so we never dedup here.
function addProviderEntry(providerId: string) {
selectedProviderPrefs.value.push({ providerId, modelId: null })
}
function moveProvider(idx: number, dir: -1 | 1) {
function removeProviderEntry(idx: number) {
selectedProviderPrefs.value.splice(idx, 1)
}
function moveProviderEntry(idx: number, dir: -1 | 1) {
const next = idx + dir
if (next < 0 || next >= selectedProviderIds.value.length) return
const arr = selectedProviderIds.value
if (next < 0 || next >= selectedProviderPrefs.value.length) return
const arr = selectedProviderPrefs.value
;[arr[idx], arr[next]] = [arr[next], arr[idx]]
}
@ -1337,9 +1347,14 @@ async function openEditModal(agent: Agent) {
selectedToolNames.value = ((boundToolsRes as any).data || [])
.filter((b: any) => b.enabled)
.map((b: any) => b.toolName)
selectedProviderIds.value = ((providerPrefsRes as any).data || [])
selectedProviderPrefs.value = ((providerPrefsRes as any).data || [])
.filter((b: any) => b.enabled)
.map((b: any) => b.providerId)
.map((b: any) => ({
providerId: b.providerId,
// modelId arrives as a string (LongString) or null; normalise to keep
// the dropdown's option values type-aligned.
modelId: b.modelId != null ? String(b.modelId) : null,
}))
} catch {
mcToast.error(t('agents.messages.loadFailed'))
}
@ -1417,7 +1432,7 @@ async function saveAgent() {
try {
await agentBindingApi.setSkills(agentId, skillIdsToSave)
await agentBindingApi.setTools(agentId, toolNamesToSave)
await agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value)
await agentBindingApi.setProviderPreferences(agentId, selectedProviderPrefs.value)
// KB access scope. Issue #304: when wiki_disabled is on the agent
// sees zero KBs regardless of the binding list, so clear the save
// payload same pattern as skills/tools above. Empty save leaves
@ -1931,8 +1946,12 @@ html.dark .seg-count.warn {
display: inline-flex; align-items: center; justify-content: center;
background: var(--mc-primary); color: white; font-size: 11px; font-weight: 700; flex-shrink: 0;
}
.provider-pref-name { font-size: 14px; color: var(--mc-text-primary); flex: 1; }
.provider-pref-id { font-size: 12px; color: var(--mc-text-tertiary); font-family: ui-monospace, monospace; }
.provider-pref-name { font-size: 14px; color: var(--mc-text-primary); flex: 0 0 auto; min-width: 96px; }
.provider-pref-model {
flex: 1; min-width: 0; height: 28px; padding: 0 8px;
border: 1px solid var(--mc-border-light); border-radius: 6px;
background: var(--mc-bg); color: var(--mc-text-primary); font-size: 13px; cursor: pointer;
}
.provider-pref-btn {
border: 1px solid var(--mc-border-light); background: var(--mc-bg);
width: 26px; height: 26px; border-radius: 6px; cursor: pointer;