package vip.mate.llm.routing; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; 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; import vip.mate.skill.runtime.model.ResolvedSkill; import java.util.ArrayList; import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; /** * Capability-aware provider routing. * *

Given an agent's bound skills, aggregates the {@code requires-model} * capabilities they declare and uses that to: *

* *

Binding data is read through {@link AgentBindingResolver}, an * abstraction declared in this package so the routing layer never depends * on the agent layer directly. */ @Slf4j @Service @RequiredArgsConstructor public class ProviderRouter { private final SkillRuntimeService skillRuntimeService; 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 * skills bound to {@code agentId}. Returns an empty set when no * bindings exist or no skill declares {@code requires-model}. */ public Set aggregateModelNeeds(Long agentId) { if (agentId == null || skillRuntimeService == null) return Set.of(); Set boundSkillIds = bindingService.getBoundSkillIds(agentId); if (boundSkillIds == null || boundSkillIds.isEmpty()) return Set.of(); List all = skillRuntimeService.resolveAllSkillsStatus(); Set needs = new LinkedHashSet<>(); for (ResolvedSkill r : all) { if (r == null || r.getId() == null) continue; if (!boundSkillIds.contains(r.getId())) continue; SkillManifest m = r.getManifest(); if (m == null) continue; List declared = m.getRequiresModel(); if (declared == null || declared.isEmpty()) continue; needs.addAll(declared); } return needs; } /** * Diagnostic check: does the chosen primary model satisfy every * skill-declared capability? Logs a single WARN per gap. * *

Intentionally never throws — this is observability, not policy. */ public void diagnosePrimary(Long agentId, ModelConfigEntity primary) { if (primary == null || agentId == null) return; Set needs = aggregateModelNeeds(agentId); if (needs.isEmpty()) return; EnumSet resolved = capabilityService.resolve( primary.getModelName(), primary.getModalities()); for (String need : needs) { Modality required = mapToModality(need); if (required == null) continue; // capability we can't translate (e.g. function_calling) — skip if (!resolved.contains(required)) { log.warn("[ProviderRouter] agent={} primary={}/{} missing capability '{}' " + "required by bound skills (resolved: {})", agentId, primary.getProvider(), primary.getModelName(), need, resolved); } } } /** * Translate a manifest {@code requires-model} token to a * {@link Modality}. Tokens that don't map (e.g. {@code function_calling}, * {@code long_context_100k}) return null — the caller skips diagnostics * for them rather than emit a noisy warning we can't act on yet. */ private Modality mapToModality(String need) { if (need == null) return null; String n = need.trim().toLowerCase(); return switch (n) { case "vision", "image", "vl" -> Modality.VISION; case "video" -> Modality.VIDEO; case "audio", "speech" -> Modality.AUDIO; default -> null; }; } /** * Convenience for tests / health checks: return the current model's * capability resolution as a structured summary (provider/model → * modalities). */ public String summarize(Long agentId) { try { ModelConfigEntity primary = modelConfigService.getDefaultModel(); EnumSet resolved = capabilityService.resolve( primary.getModelName(), primary.getModalities()); Set needs = aggregateModelNeeds(agentId); return String.format("primary=%s/%s modalities=%s needs=%s", primary.getProvider(), primary.getModelName(), resolved, needs); } catch (Exception e) { return "ProviderRouter summary unavailable: " + e.getMessage(); } } // ==================== chain reorder ==================== /** * Re-rank an already preference-ordered provider list so providers * that satisfy the agent's bound-skill {@code requires-model} union * float to the head. Stable order otherwise — providers that don't * satisfy keep their existing relative order. * *

Called when building the fallback chain, after the user-preferences * reorder. Only acts when bound skills actually declared * {@code requires-model}; otherwise returns the input untouched. */ public List reorderForCapabilities(Long agentId, List ordered) { if (ordered == null || ordered.isEmpty()) return ordered; Set needs = aggregateModelNeeds(agentId); if (needs.isEmpty()) return ordered; Set requiredModalities = needs.stream() .map(this::mapToModality) .filter(java.util.Objects::nonNull) .collect(java.util.stream.Collectors.toCollection( () -> EnumSet.noneOf(Modality.class))); if (requiredModalities.isEmpty()) { // No modality-mapped need (e.g. only function_calling // declared) — let the existing order win. return ordered; } List satisfying = new ArrayList<>(); List rest = new ArrayList<>(); for (ModelProviderEntity p : ordered) { if (providerSatisfies(p, requiredModalities)) satisfying.add(p); else rest.add(p); } if (satisfying.isEmpty() || satisfying.size() == ordered.size()) { // Either nothing matches (let preference order ride) or // everything matches (no work to do). return ordered; } log.info("[ProviderRouter] agent={} reorder: {} provider(s) lifted for needs={}", agentId, satisfying.size(), requiredModalities); List reordered = new ArrayList<>(ordered.size()); reordered.addAll(satisfying); reordered.addAll(rest); return reordered; } /** * Pick a primary model using a two-pass strategy. * *

Pass 1 (capability-gated): preferred providers → global default. *

Pass 2 (unconstrained fallback): preferred providers → global default. * *

When no preferred providers are configured the preferred branches * are skipped, preserving the legacy behaviour. */ public ModelConfigEntity selectPrimary(Long agentId, ModelConfigEntity globalDefault) { if (agentId == null) return globalDefault; List preferred = bindingService.getPreferredProviderIds(agentId); Set requiredModalities = resolveRequiredModalities(agentId); // Pass 1: capability-satisfying providers (preferred first, global fallback) if (requiredModalities != null) { // 1a. preferred providers satisfying capabilities for (String providerId : preferred) { ModelConfigEntity candidate = pickProviderDefault(providerId); if (candidate == null) continue; if (satisfies(candidate, requiredModalities)) { log.info("[ProviderRouter] agent={} primary={}/{} (preferred, satisfies {})", agentId, candidate.getProvider(), candidate.getModelName(), requiredModalities); return candidate; } } // 1b. global default satisfying capabilities if (globalDefault != null && satisfies(globalDefault, requiredModalities)) { log.info("[ProviderRouter] agent={} primary={}/{} (global, satisfies {})", agentId, globalDefault.getProvider(), globalDefault.getModelName(), requiredModalities); return globalDefault; } } // Pass 2: unconstrained (capability ignored — last resort) // 2a. any available preferred provider for (String providerId : preferred) { ModelConfigEntity candidate = pickProviderDefault(providerId); if (candidate == null) continue; log.info("[ProviderRouter] agent={} primary={}/{} (preferred, unconstrained)", agentId, candidate.getProvider(), candidate.getModelName()); return candidate; } // 2b. global default (ultimate fallback) if (globalDefault != null) { log.info("[ProviderRouter] agent={} primary={}/{} (global default)", agentId, globalDefault.getProvider(), globalDefault.getModelName()); return globalDefault; } return null; } /** Returns null when no capabilities are required (skips Pass 1). */ private Set resolveRequiredModalities(Long agentId) { Set needs = aggregateModelNeeds(agentId); if (needs == null || needs.isEmpty()) return null; Set mods = needs.stream() .map(this::mapToModality) .filter(java.util.Objects::nonNull) .collect(java.util.stream.Collectors.toCollection( () -> EnumSet.noneOf(Modality.class))); return mods.isEmpty() ? null : mods; } private boolean satisfies(ModelConfigEntity model, Set required) { return capabilityService.resolve(model.getModelName(), model.getModalities()) .containsAll(required); } private ModelConfigEntity pickProviderDefault(String providerId) { if (providerId == null || providerId.isBlank()) return null; try { // 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) { // getPrimaryChatModelByProvider can return null or throw when // the provider has no enabled chat model; treat both as // "no candidate from this provider". return null; } } private boolean providerSatisfies(ModelProviderEntity provider, Set needs) { ModelConfigEntity def = pickProviderDefault(provider.getProviderId()); if (def == null) return false; return capabilityService.resolve(def.getModelName(), def.getModalities()).containsAll(needs); } }