feat(llm): provider liveness model + honor requireApiKey on chat path

Phase 1 of the model-module refactor: combine pool / cooldown / probe-
completion signals into a single Liveness state surfaced through the
provider DTO, so the dropdown stops listing providers that are provably
unreachable. Zero schema change; one PR backend + frontend.

Backend
- Liveness enum with five mutually-exclusive states: LIVE, COOLDOWN,
  REMOVED, UNPROBED, UNCONFIGURED. Computed in ModelProviderService
  from AvailableProviderPool / ProviderHealthTracker / ProviderInitProbe
  snapshots batched once per listProviders() call.
- ProviderInitProbe.hasBeenProbed exposes a monotonic Set so the UI
  can distinguish 'still booting' from 'probed and removed' — without
  it the startup window flashes false REMOVED states.
- ProviderInfoDTO gains liveness + unavailableReason +
  cooldownRemainingMs + lastProbedAtMs. The legacy 'available' boolean
  stays but is now derived from liveness == LIVE so the chat fallback
  walker and the dropdown agree about what's usable.
- ProviderInitProbe injected into ModelProviderService via
  ObjectProvider to break the startup cycle (probe already depends on
  the service).

Frontend
- ProviderInfo type extended with liveness + the three detail fields.
- ModelSelector filters UNCONFIGURED + REMOVED out of the dropdown,
  shows COOLDOWN / UNPROBED with a status dot and dimmed rows that the
  user can still click to override.
- ProviderCard renders a five-state badge driven by liveness instead
  of the old configured + pool-entry combo. Reprobe button now keys
  off liveness in {REMOVED, COOLDOWN}.
- useProviders drops loadProviderPool / providerPool — pool data ships
  inline on each ProviderInfo, saves a round trip per page load and
  keeps a single source of truth.
- i18n: 8 new keys across zh-CN and en-US for liveness labels and the
  cooldown countdown tooltips.

Bonus fix (discovered during verification): AgentGraphBuilder.buildOpenAiApi
hard-required a usable API key on every OpenAI-compat provider, ignoring
the per-provider requireApiKey flag. That bug stranded keyless local
runtimes (LM Studio / MLX / llama.cpp) the moment a user actually
launched them; Ollama only worked by accident because its seed row
carries a placeholder string in api_key. keyRequired now honors
requireApiKey, and Spring AI's NoopApiKey is used when no key is needed
so the Authorization header is omitted entirely.

Test
- ModelProviderServiceLivenessTest covers all five Liveness states +
  the probe-bean-absent fallback branch.
- vip.mate.llm.** suite (118 tests) green; vue-tsc clean.
- End-to-end browser sanity: 27 raw providers reduce to 6 LIVE groups
  in the chat dropdown; LM Studio / MLX / llama.cpp render REMOVED red
  badges with reprobe buttons; cloud providers without keys show
  UNCONFIGURED.
This commit is contained in:
matevip 2026-04-28 14:59:11 +08:00
parent b4aef56c89
commit c0c642380a
12 changed files with 317 additions and 95 deletions

View File

@ -15,6 +15,8 @@ import lombok.extern.slf4j.Slf4j;
// PR-0b: Anthropic imports moved with the construction code into AgentAnthropicChatModelBuilder.
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.NoopApiKey;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
@ -1006,7 +1008,13 @@ public class AgentGraphBuilder {
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
String apiKey = provider.getApiKey();
if (!modelProviderService.hasUsableApiKey(apiKey)) {
// Honor the provider's requireApiKey flag instead of hard-failing on every empty key.
// Local + key-free providers (Ollama, LM Studio, MLX, llama.cpp, OpenCode) declare
// requireApiKey=false; for them an empty / placeholder key means "no Authorization
// header" — Spring AI's NoopApiKey expresses that. Without this the chat path
// rejected providers that probe / discovery / connection-test all considered usable.
boolean keyRequired = !Boolean.FALSE.equals(provider.getRequireApiKey());
if (keyRequired && !modelProviderService.hasUsableApiKey(apiKey)) {
throw new MateClawException("err.agent.provider_apikey_invalid", "Provider API Key 未配置或无效: " + provider.getProviderId());
}
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
@ -1042,9 +1050,12 @@ public class AgentGraphBuilder {
boolean kimiSearchEnabled = isKimiProvider(provider)
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey))
? new SimpleApiKey(apiKey.trim())
: new NoopApiKey();
return new OpenAiApi(
baseUrl,
new SimpleApiKey(apiKey.trim()),
apiKeyImpl,
headers,
completionsPath,
"/v1/embeddings",

View File

@ -15,6 +15,7 @@ import vip.mate.llm.service.ModelProviderService;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
/**
@ -51,6 +52,14 @@ public class ProviderInitProbe {
private final AvailableProviderPool pool;
private final Map<ModelProtocol, ProviderProbeStrategy> strategies;
/**
* RFC-073: providers we've made a probe decision for (success / fail /
* deferred / fail-open). Lets the UI distinguish "still booting" from
* "probed and removed" without inventing a parallel state machine.
* Monotonic entries are never removed; re-probing simply re-asserts.
*/
private final Set<String> probedProviderIds = ConcurrentHashMap.newKeySet();
public ProviderInitProbe(ModelProviderMapper providerMapper,
ModelProviderService providerService,
AvailableProviderPool pool,
@ -122,6 +131,7 @@ public class ProviderInitProbe {
log.debug("[ProviderInitProbe] no probe strategy for {} (protocol={}), defaulting to in-pool",
provider.getProviderId(), provider.getChatModel());
pool.add(provider.getProviderId());
probedProviderIds.add(provider.getProviderId());
continue;
}
futures.put(provider.getProviderId(),
@ -161,6 +171,7 @@ public class ProviderInitProbe {
log.warn("[ProviderInitProbe] provider={} FAIL ({} ms): {}",
id, result.latencyMs(), result.errorMessage());
}
probedProviderIds.add(id);
}
log.info("[ProviderInitProbe] done — passed={}, failed={}, deferred={}, pool size={}",
passed, failed, deferred, pool.snapshot().size());
@ -193,6 +204,7 @@ public class ProviderInitProbe {
if (strategy == null) {
// No strategy for this protocol fail-open: assume usable.
pool.add(providerId);
probedProviderIds.add(providerId);
return ProbeResult.ok(0);
}
ProbeResult result = strategy.probe(provider);
@ -202,9 +214,21 @@ public class ProviderInitProbe {
pool.remove(providerId, AvailableProviderPool.RemovalSource.INIT_PROBE,
"reprobe failed: " + result.errorMessage());
}
probedProviderIds.add(providerId);
return result;
}
/**
* RFC-073: has this provider been through at least one probe attempt
* (success, failure, deferred fail-open, or strategy-missing fail-open)?
* Returns false during the startup window before {@code probeAllConfigured}
* has touched it. Used by {@code ModelProviderService} to distinguish
* UNPROBED from REMOVED in the UI.
*/
public boolean hasBeenProbed(String providerId) {
return providerId != null && probedProviderIds.contains(providerId);
}
private List<ModelProviderEntity> listConfiguredProviders() {
return providerMapper.selectList(null).stream()
.filter(p -> providerService.isProviderConfigured(p.getProviderId()))

View File

@ -0,0 +1,22 @@
package vip.mate.llm.model;
/**
* RFC-073: runtime liveness state of a provider, surfaced to the UI so the
* dropdown / settings page can show truth instead of "configured = available".
*
* <p>Computed by {@code ModelProviderService.computeLiveness} from three
* orthogonal signals: configuration completeness, init-probe progress, and
* pool / cooldown membership. The five values are mutually exclusive.</p>
*/
public enum Liveness {
/** In pool, not in cooldown. The default healthy state. */
LIVE,
/** In pool but in transient cooldown (consecutive failures tripped the threshold). */
COOLDOWN,
/** Probed and removed from pool with a HARD reason (auth, billing, model-not-found, init-probe failed). */
REMOVED,
/** Not yet probed — startup window or no probe strategy registered for this protocol. */
UNPROBED,
/** User-side configuration is incomplete (missing api_key / oauth token / base_url). */
UNCONFIGURED
}

View File

@ -31,4 +31,12 @@ public class ProviderInfoDTO {
private Long oauthExpiresAt;
/** RFC-009 P3.5: position in the failover chain (0 = excluded, 1..N = priority). */
private Integer fallbackPriority;
/** RFC-073: combined runtime state — UI source of truth for "is this provider usable right now". */
private Liveness liveness;
/** Human-readable reason populated only when liveness ∈ {REMOVED, COOLDOWN}. */
private String unavailableReason;
/** Epoch ms of the most recent removal, populated only when liveness == REMOVED. */
private Long lastProbedAtMs;
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
private Long cooldownRemainingMs;
}

View File

@ -11,6 +11,9 @@ import org.springframework.util.StringUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.failover.ProviderHealthTracker;
import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper;
@ -32,6 +35,15 @@ public class ModelProviderService {
private final ApplicationEventPublisher eventPublisher;
/** Lazy provider — avoids forcing the bean to exist in test contexts that don't load the anthropic package. */
private final ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthServiceProvider;
/** RFC-073: pool / cooldown / probe-completion signals that drive {@link Liveness}. */
private final AvailableProviderPool providerPool;
private final ProviderHealthTracker providerHealthTracker;
/**
* Lazy provider {@link ProviderInitProbe} depends on this service, so direct injection
* would create a startup cycle. The probe always exists at runtime; the indirection only
* defers Spring's wiring decision past construction.
*/
private final ObjectProvider<ProviderInitProbe> providerInitProbeProvider;
private final ObjectMapper objectMapper = new ObjectMapper();
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
@ -67,8 +79,13 @@ public class ModelProviderService {
.orderByAsc(ModelProviderEntity::getName));
Map<String, List<ModelConfigEntity>> modelsByProvider = modelConfigService.listModels().stream()
.collect(Collectors.groupingBy(ModelConfigEntity::getProvider));
// RFC-073: batch the runtime snapshots once so each toProviderInfo call is O(1)
// instead of N pool/tracker round-trips per render.
LivenessContext liveness = livenessContext();
return providers.stream().map(provider -> toProviderInfo(provider, modelsByProvider.get(provider.getProviderId()))).toList();
return providers.stream()
.map(provider -> toProviderInfo(provider, modelsByProvider.get(provider.getProviderId()), liveness))
.toList();
}
public ProviderInfoDTO updateProviderConfig(String providerId, ProviderConfigRequest request) {
@ -229,6 +246,12 @@ public class ModelProviderService {
}
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider, List<ModelConfigEntity> models) {
return toProviderInfo(provider, models, livenessContext());
}
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider,
List<ModelConfigEntity> models,
LivenessContext liveness) {
ProviderInfoDTO dto = new ProviderInfoDTO();
dto.setId(provider.getProviderId());
dto.setName(provider.getName());
@ -242,9 +265,15 @@ public class ModelProviderService {
dto.setFreezeUrl(Boolean.TRUE.equals(provider.getFreezeUrl()));
dto.setRequireApiKey(Boolean.TRUE.equals(provider.getRequireApiKey()));
boolean configured = isProviderConfigured(provider);
boolean available = configured && models != null && !models.isEmpty();
// RFC-073: `available` retains its boolean meaning ("usable right now") but is now
// gated on Liveness.LIVE rather than just configuration completeness, so the chat
// path and the dropdown stop disagreeing about local providers.
Liveness providerLiveness = computeLiveness(provider, configured, liveness);
boolean available = providerLiveness == Liveness.LIVE && models != null && !models.isEmpty();
dto.setConfigured(configured);
dto.setAvailable(available);
dto.setLiveness(providerLiveness);
applyLivenessDetails(dto, provider.getProviderId(), providerLiveness, liveness);
dto.setApiKey(maskApiKey(provider.getApiKey()));
dto.setBaseUrl(provider.getBaseUrl());
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
@ -363,4 +392,56 @@ public class ModelProviderService {
return "{}";
}
}
// ============================================================
// RFC-073: Liveness computation
// ============================================================
/** Take one snapshot per render-batch so {@link #toProviderInfo} stays O(1) per provider. */
private LivenessContext livenessContext() {
return new LivenessContext(providerPool.snapshot(), providerHealthTracker.snapshot(),
providerInitProbeProvider.getIfAvailable());
}
private Liveness computeLiveness(ModelProviderEntity provider, boolean configured, LivenessContext ctx) {
if (!configured) return Liveness.UNCONFIGURED;
String id = provider.getProviderId();
// Probe absent in test contexts fail-open to LIVE so test fixtures don't trip on UNPROBED.
if (ctx.initProbe() != null && !ctx.initProbe().hasBeenProbed(id)) {
return Liveness.UNPROBED;
}
AvailableProviderPool.RemovalReason reason = ctx.poolSnapshot().get(id);
boolean inPool = ctx.poolSnapshot().containsKey(id) && reason == null;
if (!inPool) return Liveness.REMOVED;
ProviderHealthTracker.ProviderHealthSnapshot health = ctx.healthSnapshot().get(id);
if (health != null && health.cooldownRemainingMs() > 0) return Liveness.COOLDOWN;
return Liveness.LIVE;
}
private void applyLivenessDetails(ProviderInfoDTO dto, String providerId,
Liveness liveness, LivenessContext ctx) {
switch (liveness) {
case REMOVED -> {
AvailableProviderPool.RemovalReason reason = ctx.poolSnapshot().get(providerId);
if (reason != null) {
dto.setUnavailableReason(reason.message());
dto.setLastProbedAtMs(reason.removedAtMs());
}
}
case COOLDOWN -> {
ProviderHealthTracker.ProviderHealthSnapshot health = ctx.healthSnapshot().get(providerId);
if (health != null) {
dto.setCooldownRemainingMs(health.cooldownRemainingMs());
}
dto.setUnavailableReason("provider in cooldown after consecutive failures");
}
default -> { /* LIVE / UNPROBED / UNCONFIGURED — no extra fields */ }
}
}
/** Per-render snapshot of pool / cooldown / probe-completion state. */
private record LivenessContext(
Map<String, AvailableProviderPool.RemovalReason> poolSnapshot,
Map<String, ProviderHealthTracker.ProviderHealthSnapshot> healthSnapshot,
ProviderInitProbe initProbe) {}
}

View File

@ -34,12 +34,24 @@
<div class="model-group-header">
<span class="model-group-header__name">{{ group.provider.name }}</span>
<span v-if="group.provider.isLocal" class="model-group-header__badge model-group-header__badge--local">Local</span>
<!-- RFC-073: liveness dot. UNPROBED = grey (still booting),
COOLDOWN = amber (transient backoff). LIVE has no dot. -->
<span
v-if="group.provider.liveness === 'UNPROBED'"
class="model-group-header__dot model-group-header__dot--unprobed"
:title="$t('chat.modelLivenessUnprobed')"
></span>
<span
v-else-if="group.provider.liveness === 'COOLDOWN'"
class="model-group-header__dot model-group-header__dot--cooldown"
:title="$t('chat.modelLivenessCooldown', { seconds: cooldownSeconds(group.provider) })"
></span>
</div>
<div
v-for="item in group.models"
:key="item.value"
class="model-dropdown-item"
:class="{ active: item.value === activeValue }"
:class="{ active: item.value === activeValue, dimmed: group.provider.liveness === 'COOLDOWN' || group.provider.liveness === 'UNPROBED' }"
@click="handleSelect(item.value)"
>
<span class="model-dropdown-item__name">{{ item.name }}</span>
@ -118,12 +130,19 @@ function toggle() {
}
// provider
// RFC-073: UNCONFIGURED / REMOVEDUNPROBED + COOLDOWN
function isHidden(p: ProviderInfo): boolean {
// liveness 退 available UI
if (!p.liveness) return !p.available
return p.liveness === 'UNCONFIGURED' || p.liveness === 'REMOVED'
}
const groups = computed<ModelGroup[]>(() => {
const cloud: ModelGroup[] = []
const local: ModelGroup[] = []
for (const provider of props.providers) {
if (!provider.available) continue
if (isHidden(provider)) continue
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
if (allModels.length === 0) continue
@ -146,6 +165,10 @@ const groups = computed<ModelGroup[]>(() => {
return [...cloud, ...local]
})
function cooldownSeconds(provider: ProviderInfo): number {
return Math.max(1, Math.ceil((provider.cooldownRemainingMs || 0) / 1000))
}
const totalCount = computed(() =>
groups.value.reduce((n, g) => n + g.models.length, 0)
)
@ -329,6 +352,26 @@ watch(open, async (isOpen) => {
color: var(--mc-success, #34c759);
}
/* RFC-073 liveness dot — sits next to the provider name */
.model-group-header__dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
cursor: help;
}
.model-group-header__dot--unprobed {
background: var(--mc-text-quaternary, #c0c4cc);
animation: model-dot-pulse 1.6s ease-in-out infinite;
}
.model-group-header__dot--cooldown {
background: #f59e0b;
}
@keyframes model-dot-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ---- Items ---- */
.model-dropdown-item {
@ -350,6 +393,14 @@ watch(open, async (isOpen) => {
background: var(--mc-primary-bg);
}
/* RFC-073: cooldown / unprobed models render dimmed but still selectable. */
.model-dropdown-item.dimmed {
opacity: 0.55;
}
.model-dropdown-item.dimmed:hover {
opacity: 0.85;
}
.model-dropdown-item__name {
font-size: 13px;
color: var(--mc-text-primary);

View File

@ -143,6 +143,9 @@ export default {
switchModelFailed: 'Failed to switch model',
searchModel: 'Search models…',
noMatchModel: 'No matching models',
// RFC-073: liveness hints shown in the model dropdown header
modelLivenessUnprobed: 'Checking availability',
modelLivenessCooldown: 'In cooldown ({seconds}s remaining)',
uploadFailed: 'File upload failed',
dropToUpload: 'Drop files or folders here',
copyFailed: 'Copy failed',
@ -353,6 +356,13 @@ export default {
poolReprobing: 'Probing...',
poolReprobeOk: 'Probe passed; back in the available pool',
poolReprobeFail: 'Probe failed: {error}',
// RFC-073: status-pill labels driven by Liveness
livenessLive: 'Live',
livenessCooldown: 'Cooling down',
livenessRemoved: 'Disconnected',
livenessUnprobed: 'Probing',
livenessUnprobedTooltip: 'Checking availability after startup; will refresh shortly',
livenessUnconfigured: 'Needs setup',
searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).',
searchStrategyDefault: 'Default',
oauthTitle: 'OpenAI OAuth Login',

View File

@ -143,6 +143,9 @@ export default {
switchModelFailed: '切换模型失败',
searchModel: '搜索模型…',
noMatchModel: '没有匹配的模型',
// RFC-073: liveness hints shown in the model dropdown header
modelLivenessUnprobed: '正在检查可用性',
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
uploadFailed: '文件上传失败',
dropToUpload: '拖放文件或文件夹到此处',
copyFailed: '复制失败',
@ -343,6 +346,13 @@ export default {
poolReprobing: '检测中...',
poolReprobeOk: '检测通过,已重新加入可用池',
poolReprobeFail: '检测失败:{error}',
// RFC-073: status-pill labels driven by Liveness
livenessLive: '可用',
livenessCooldown: '冷却中',
livenessRemoved: '未连接',
livenessUnprobed: '检测中',
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
livenessUnconfigured: '需要配置',
searchHint: '开启后大模型将在回答时自动调用内置搜索引擎获取实时信息DashScope/Kimi/OpenAI 支持)。',
searchStrategyDefault: '默认',
oauthTitle: 'OpenAI OAuth 登录',

View File

@ -368,7 +368,7 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
{ key: 'connection_mode', label: '接入模式', placeholder: '', type: 'select', defaultValue: 'stream', tooltip: 'Stream 长连接无需公网 IP推荐Webhook 需要公网回调地址', options: [{ label: 'Stream长连接推荐', value: 'stream' }, { label: 'WebhookHTTP 回调)', value: 'webhook' }] },
{ key: 'message_type', label: '消息格式', placeholder: '', type: 'select', defaultValue: 'markdown', tooltip: 'markdown: 普通消息card: AI 流式卡片(需配置模板 ID', options: [{ label: 'Markdown', value: 'markdown' }, { label: 'AI Card流式卡片', value: 'card' }] },
{ key: 'card_template_id', label: '卡片模板 ID', placeholder: 'dt_card_1234', required: true, type: 'text', tooltip: '钉钉 AI Card 模板 ID', showIf: { field: 'message_type', value: 'card' } },
{ key: 'robot_code', label: '机器人编码', placeholder: 'dingxxxxxxxx', type: 'text', tooltip: '机器人 robot_code群聊场景建议配置', showIf: { field: 'message_type', value: 'card' } },
{ key: 'robot_code', label: '机器人编码', placeholder: '留空将自动使用 AppKey适用于自建应用机器人', type: 'text', tooltip: '钉钉机器人 robotCode用于发送附件图片 / DOCX和 AI Card。绝大多数自建应用机器人 robotCode == AppKey不填会自动 fallback只有第三方应用 / 单独申请的机器人才必须显式填' },
],
feishu: [
{ key: 'app_id', label: 'App ID', placeholder: 'cli_xxxxxxxx', required: true, type: 'text', tooltip: '飞书开放平台应用的 App ID' },
@ -610,6 +610,16 @@ export interface ProviderModelInfo {
supportsThinking?: boolean
}
/**
* RFC-073: combined runtime state of a provider.
* - LIVE pool member, not in cooldown usable
* - COOLDOWN pool member, transient backoff after consecutive failures
* - REMOVED probed and HARD-removed (auth/billing/init-probe failure)
* - UNPROBED startup window, decision not made yet
* - UNCONFIGURED user hasn't supplied required credentials
*/
export type Liveness = 'LIVE' | 'COOLDOWN' | 'REMOVED' | 'UNPROBED' | 'UNCONFIGURED'
export interface ProviderInfo {
id: string
name: string
@ -634,6 +644,14 @@ export interface ProviderInfo {
oauthExpiresAt?: number
/** RFC-009 P3.5: position in the multi-model failover chain (0 = excluded). */
fallbackPriority?: number
/** RFC-073: runtime state — UI source of truth for whether this provider is usable now. */
liveness?: Liveness
/** Populated only when liveness ∈ {REMOVED, COOLDOWN}. */
unavailableReason?: string
/** Epoch ms of the most recent removal, populated only when liveness == REMOVED. */
lastProbedAtMs?: number
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
cooldownRemainingMs?: number
}
export interface ActiveModelsInfo {

View File

@ -27,37 +27,42 @@
>
{{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }}
</span>
<!-- RFC-009 Phase 4: pool status. Three mutually-exclusive states:
removed > cooldown > in-pool. Hidden when no pool data is loaded yet
or when the provider isn't configured (pool would never have probed it). -->
<template v-if="poolEntry && provider.configured">
<span
v-if="!poolEntry.inPool"
class="provider-badge pool-removed"
:title="t('settings.model.poolBadgeRemovedTitle', {
source: poolSourceLabel(poolEntry.removalSource),
message: poolEntry.removalMessage || '—'
})"
>
{{ t('settings.model.poolBadgeRemoved') }}
</span>
<span
v-else-if="poolEntry.inCooldown"
class="provider-badge pool-cooldown"
:title="t('settings.model.poolBadgeCooldownTitle', {
seconds: Math.ceil(poolEntry.cooldownRemainingMs / 1000)
})"
>
{{ t('settings.model.poolBadgeCooldown') }}
</span>
<span
v-else
class="provider-badge pool-in"
:title="t('settings.model.poolBadgeInPoolTitle')"
>
{{ t('settings.model.poolBadgeInPool') }}
</span>
</template>
<!-- RFC-073: liveness badge. Single source of truth replacing the old
configured / pool-entry combo. UNCONFIGURED renders no badge the
status pill on the right already says "needs configuration". -->
<span
v-if="provider.liveness === 'LIVE'"
class="provider-badge pool-in"
:title="t('settings.model.poolBadgeInPoolTitle')"
>
{{ t('settings.model.poolBadgeInPool') }}
</span>
<span
v-else-if="provider.liveness === 'COOLDOWN'"
class="provider-badge pool-cooldown"
:title="t('settings.model.poolBadgeCooldownTitle', {
seconds: Math.max(1, Math.ceil((provider.cooldownRemainingMs || 0) / 1000))
})"
>
{{ t('settings.model.poolBadgeCooldown') }}
</span>
<span
v-else-if="provider.liveness === 'REMOVED'"
class="provider-badge pool-removed"
:title="t('settings.model.poolBadgeRemovedTitle', {
source: t('settings.model.poolSourceInitProbe'),
message: provider.unavailableReason || '—'
})"
>
{{ t('settings.model.poolBadgeRemoved') }}
</span>
<span
v-else-if="provider.liveness === 'UNPROBED'"
class="provider-badge pool-unprobed"
:title="t('settings.model.livenessUnprobedTooltip')"
>
{{ t('settings.model.livenessUnprobed') }}
</span>
</div>
<p class="provider-id">{{ provider.id }}</p>
</div>
@ -115,10 +120,11 @@
>
{{ t('common.delete') }}
</button>
<!-- RFC-009 Phase 4: manual reprobe visible when the provider has been
HARD-removed from the pool, lets the user recover without restart. -->
<!-- RFC-073: manual reprobe visible when the provider was HARD-removed,
lets the user recover without restart. Also useful in COOLDOWN to
short-circuit the wait. -->
<button
v-if="poolEntry && !poolEntry.inPool && provider.configured"
v-if="provider.liveness === 'REMOVED' || provider.liveness === 'COOLDOWN'"
class="card-btn"
:class="{ testing: reprobing }"
:disabled="reprobing"
@ -142,15 +148,12 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
import type { ProviderPoolEntry } from '@/api'
defineProps<{
provider: ProviderInfo
connectionTestingId: string | null
connectionResults: Record<string, any>
// RFC-009 Phase 4: pool status for this provider; null when pool API hasn't loaded yet.
poolEntry?: ProviderPoolEntry | null
// RFC-009 Phase 4: true while a manual reprobe is in flight for this provider.
// RFC-073: true while a manual reprobe is in flight for this provider.
reprobing?: boolean
isProviderActive: (provider: ProviderInfo) => boolean
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
@ -167,18 +170,6 @@ defineEmits<{
}>()
const { t } = useI18n()
/** Translate the backend RemovalSource enum into a human-readable label. */
function poolSourceLabel(source: string | null): string {
switch (source) {
case 'AUTH_ERROR': return t('settings.model.poolSourceAuthError')
case 'BILLING': return t('settings.model.poolSourceBilling')
case 'MODEL_NOT_FOUND': return t('settings.model.poolSourceModelNotFound')
case 'INIT_PROBE': return t('settings.model.poolSourceInitProbe')
case 'MANUAL': return t('settings.model.poolSourceManual')
default: return source || '—'
}
}
</script>
<style scoped>
@ -230,6 +221,14 @@ function poolSourceLabel(source: string | null): string {
.provider-badge.pool-in { background: rgba(34, 197, 94, 0.12); color: #16a34a; cursor: help; }
.provider-badge.pool-cooldown { background: rgba(245, 158, 11, 0.14); color: #b45309; cursor: help; }
.provider-badge.pool-removed { background: rgba(239, 68, 68, 0.14); color: #dc2626; cursor: help; }
/* RFC-073: UNPROBED — neutral grey with a gentle pulse so it's clearly transient. */
.provider-badge.pool-unprobed {
background: rgba(156, 163, 175, 0.16);
color: var(--mc-text-tertiary, #6b7280);
cursor: help;
animation: mc-card-dot-pulse 1.6s ease-in-out infinite;
}
@keyframes mc-card-dot-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }

View File

@ -24,7 +24,6 @@
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:pool-entry="providerPool[provider.id] || null"
:reprobing="reprobingId === provider.id"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
@ -54,7 +53,6 @@
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:pool-entry="providerPool[provider.id] || null"
:reprobing="reprobingId === provider.id"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
@ -157,9 +155,7 @@ const {
providerBaseUrlPlaceholder,
providerBaseUrlHint,
providerApiKeyPlaceholder,
providerPool,
reprobingId,
loadProviderPool,
reprobeProvider,
loadProviders,
loadActiveModel,
@ -192,7 +188,7 @@ const localProviders = computed(() => providers.value.filter(p => p.isLocal))
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
onMounted(async () => {
await Promise.all([loadProviders(), loadActiveModel(), loadProviderPool()])
await Promise.all([loadProviders(), loadActiveModel()])
})
async function onSaveProvider() {

View File

@ -2,7 +2,6 @@ import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api'
import type { ProviderPoolEntry } from '@/api'
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
export function useProviders() {
@ -10,9 +9,10 @@ export function useProviders() {
const providers = ref<ProviderInfo[]>([])
const activeModels = ref<ActiveModelsInfo | null>(null)
// RFC-009 Phase 4: pool snapshot, indexed by providerId for O(1) lookup in templates.
const providerPool = ref<Record<string, ProviderPoolEntry>>({})
// RFC-009 Phase 4 PR-1e: providerId currently being manually reprobed.
// RFC-073: pool / cooldown / probe state now ships inline on each ProviderInfo
// via `liveness`. Separate snapshot / indexed map removed — see ProviderCard.vue
// for how the five states render.
// PR-1e manual reprobe still tracks which provider is in flight.
const reprobingId = ref<string | null>(null)
const editingProvider = ref<ProviderInfo | null>(null)
const currentProvider = ref<ProviderInfo | null>(null)
@ -70,35 +70,16 @@ export function useProviders() {
}
/**
* RFC-009 Phase 4: fetch the pool snapshot. Best-effort if it 404s
* (older backend) or errors, the badges just don't render. Don't block
* the rest of the model settings page on it.
*/
async function loadProviderPool() {
try {
const res: any = await providerPoolApi.snapshot()
const list: ProviderPoolEntry[] = res.data || []
providerPool.value = list.reduce((acc, entry) => {
acc[entry.providerId] = entry
return acc
}, {} as Record<string, ProviderPoolEntry>)
} catch (err) {
console.warn('[ProviderPool] snapshot failed (badges will be hidden)', err)
providerPool.value = {}
}
}
/**
* RFC-009 Phase 4 PR-1e: synchronously re-probe one provider, then
* refresh the pool snapshot so the badge updates. Returns the result
* so callers can show a toast.
* RFC-073: synchronously re-probe one provider, then re-fetch the
* provider list `liveness` ships inline now so a single round-trip
* refreshes everything the UI needs.
*/
async function reprobeProvider(provider: ProviderInfo) {
reprobingId.value = provider.id
try {
const res: any = await providerPoolApi.reprobe(provider.id)
const data = res.data || {}
await loadProviderPool()
await loadProviders()
if (data.success) {
ElMessage.success(t('settings.model.poolReprobeOk'))
} else {
@ -411,11 +392,23 @@ export function useProviders() {
: t('settings.model.apiKeyInput')
})
// Utility functions
// RFC-073: status pill is driven by liveness. Falls back to the legacy
// configured/available booleans for older backends that don't yet send liveness.
function providerStatus(provider: ProviderInfo) {
if (provider.available) {
return { type: 'configured', label: t('settings.model.configured') }
switch (provider.liveness) {
case 'LIVE':
return { type: 'configured', label: t('settings.model.livenessLive') }
case 'COOLDOWN':
return { type: 'partial', label: t('settings.model.livenessCooldown') }
case 'REMOVED':
return { type: 'unavailable', label: t('settings.model.livenessRemoved') }
case 'UNPROBED':
return { type: 'partial', label: t('settings.model.livenessUnprobed') }
case 'UNCONFIGURED':
return { type: 'unavailable', label: t('settings.model.livenessUnconfigured') }
}
// Legacy backend (no liveness field)
if (provider.available) return { type: 'configured', label: t('settings.model.configured') }
if (provider.configured || (provider.models?.length || 0) + (provider.extraModels?.length || 0) > 0) {
return { type: 'partial', label: t('settings.model.partial') }
}
@ -553,10 +546,9 @@ export function useProviders() {
providerBaseUrlPlaceholder,
providerBaseUrlHint,
providerApiKeyPlaceholder,
// RFC-009 Phase 4
providerPool,
// RFC-073: pool/cooldown/probe data is now baked into providers[].liveness.
// The standalone snapshot/loadProviderPool are gone — saves a round trip.
reprobingId,
loadProviderPool,
reprobeProvider,
// Methods
loadProviders,