mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
b4aef56c89
commit
c0c642380a
@ -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",
|
||||
|
||||
@ -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()))
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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 / REMOVED;UNPROBED + 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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 登录',
|
||||
|
||||
@ -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: 'Webhook(HTTP 回调)', 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 {
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user