package vip.mate.llm.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import vip.mate.common.result.R; import vip.mate.llm.failover.AvailableProviderPool; import vip.mate.llm.failover.AvailableProviderPool.RemovalReason; import vip.mate.llm.failover.ProbeResult; import vip.mate.llm.failover.ProviderHealthTracker; import vip.mate.llm.failover.ProviderHealthTracker.ProviderHealthSnapshot; import vip.mate.llm.failover.ProviderInitProbe; import vip.mate.llm.model.ProviderInfoDTO; import vip.mate.llm.service.ModelProviderService; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * RFC-009 Phase 4 — read-only diagnostic endpoint for the provider pool. * *

Surfaces the union of three pieces of state:

* * *

Manual reprobe + auto-reprobe-on-config-change land in PR-1e.

*/ @Tag(name = "Provider 可用池") @RestController @RequestMapping("/api/v1/llm/provider-pool") @RequiredArgsConstructor public class ProviderPoolController { private final AvailableProviderPool providerPool; private final ProviderHealthTracker healthTracker; private final ModelProviderService providerService; private final ProviderInitProbe initProbe; @Operation(summary = "查询所有 provider 的池状态 + 冷却信息") @GetMapping public R> snapshot() { Map poolView = providerPool.snapshot(); Map healthView = healthTracker.snapshot(); List providers = providerService.listProviders(); List rows = new ArrayList<>(providers.size()); for (ProviderInfoDTO p : providers) { String id = p.getId(); // Pool membership: snapshot() returns id->null for in-pool, id->reason for removed. // An id missing from the snapshot has never been touched (treat as not-in-pool until first probe). boolean tracked = poolView.containsKey(id); RemovalReason reason = poolView.get(id); boolean inPool = tracked && reason == null; ProviderHealthSnapshot health = healthView.get(id); rows.add(new ProviderPoolEntryDTO( id, p.getName(), inPool, reason == null ? null : reason.source().name(), reason == null ? null : reason.message(), reason == null ? null : reason.removedAtMs(), health != null && health.cooldownRemainingMs() > 0, health == null ? 0L : health.cooldownRemainingMs(), health == null ? 0L : health.consecutiveFailures() )); } return R.ok(rows); } @Operation(summary = "手动重新探测某个 provider,立即更新池状态") @PostMapping("/{providerId}/reprobe") public R reprobe(@PathVariable String providerId) { ProbeResult result = initProbe.probeOne(providerId); return R.ok(new ReprobeResultDTO( providerId, result.success(), result.latencyMs(), result.errorMessage(), providerPool.contains(providerId) )); } /** Result of a manual reprobe. {@code inPool} reflects pool state after the probe ran. */ public record ReprobeResultDTO( String providerId, boolean success, long latencyMs, String errorMessage, boolean inPool ) {} /** * Per-row payload for the {@code /provider-pool} response. Flat record * because the UI renders each provider as one card and we want a stable * shape that's trivial to bind in TypeScript. */ public record ProviderPoolEntryDTO( String providerId, String providerName, boolean inPool, String removalSource, String removalMessage, Long removedAtMs, boolean inCooldown, long cooldownRemainingMs, long consecutiveFailures ) {} }