From c0c642380ae72351c5c1d3ce5ed96cc711c4cfaf Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 14:59:11 +0800 Subject: [PATCH] feat(llm): provider liveness model + honor requireApiKey on chat path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../vip/mate/agent/AgentGraphBuilder.java | 15 ++- .../mate/llm/failover/ProviderInitProbe.java | 24 +++++ .../java/vip/mate/llm/model/Liveness.java | 22 +++++ .../vip/mate/llm/model/ProviderInfoDTO.java | 8 ++ .../llm/service/ModelProviderService.java | 85 +++++++++++++++- .../src/components/chat/ModelSelector.vue | 55 ++++++++++- mateclaw-ui/src/i18n/locales/en-US.ts | 10 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 10 ++ mateclaw-ui/src/types/index.ts | 20 +++- .../views/Settings/Models/ProviderCard.vue | 99 +++++++++---------- .../src/views/Settings/Models/index.vue | 6 +- .../src/views/Settings/Models/useProviders.ts | 58 +++++------ 12 files changed, 317 insertions(+), 95 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/model/Liveness.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 49f5c421..45d98dd4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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", diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java index 1a679dd4..babe6bf7 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java @@ -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 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 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 listConfiguredProviders() { return providerMapper.selectList(null).stream() .filter(p -> providerService.isProviderConfigured(p.getProviderId())) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/Liveness.java b/mateclaw-server/src/main/java/vip/mate/llm/model/Liveness.java new file mode 100644 index 00000000..9ec4b6ef --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/Liveness.java @@ -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". + * + *

Computed by {@code ModelProviderService.computeLiveness} from three + * orthogonal signals: configuration completeness, init-probe progress, and + * pool / cooldown membership. The five values are mutually exclusive.

+ */ +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 +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java index eadc6fcc..3164bd09 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java @@ -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; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 5e494d53..36d278ed 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -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 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 providerInitProbeProvider; private final ObjectMapper objectMapper = new ObjectMapper(); /** Plugin-registered ChatModel instances: providerId -> ChatModel */ @@ -67,8 +79,13 @@ public class ModelProviderService { .orderByAsc(ModelProviderEntity::getName)); Map> 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 models) { + return toProviderInfo(provider, models, livenessContext()); + } + + private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider, + List 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 poolSnapshot, + Map healthSnapshot, + ProviderInitProbe initProbe) {} } diff --git a/mateclaw-ui/src/components/chat/ModelSelector.vue b/mateclaw-ui/src/components/chat/ModelSelector.vue index 6d06136a..23ce4bba 100644 --- a/mateclaw-ui/src/components/chat/ModelSelector.vue +++ b/mateclaw-ui/src/components/chat/ModelSelector.vue @@ -34,12 +34,24 @@
{{ group.provider.name }} Local + + +
{{ item.name }} @@ -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(() => { 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(() => { 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); diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 2bf3ac8f..f2c2f454 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index f34df07b..344081e6 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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 登录', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 8f2907ba..207db3f7 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -368,7 +368,7 @@ export const CHANNEL_FIELD_DEFS: Record = { { 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 { diff --git a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue index c03acd34..4c59c8b2 100644 --- a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue +++ b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue @@ -27,37 +27,42 @@ > {{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }} - - + + + {{ t('settings.model.poolBadgeInPool') }} + + + {{ t('settings.model.poolBadgeCooldown') }} + + + {{ t('settings.model.poolBadgeRemoved') }} + + + {{ t('settings.model.livenessUnprobed') }} +

{{ provider.id }}

@@ -115,10 +120,11 @@ > {{ t('common.delete') }} - +