diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index 5eb78b798f..f3353ebd45 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -5,10 +5,57 @@ import { apiClient } from '../client' -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok' +export type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'grok' + | 'antigravity' + | 'kimi' + | 'zhipu' + | 'deepseek' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' export type APIMode = 'chat_completions' | 'responses' +/** + * probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本); + * quota_probe = 探活 + 配额快照挂主模型行。 + */ +export type CheckMode = 'probe' | 'quota' | 'quota_probe' + +/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */ +export interface MonitorQuotaTier { + /** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */ + window: string + /** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */ + label?: string + used_percent: number + used?: number + limit?: number + /** RFC3339;空表示无重置时间 */ + reset_at?: string +} + +export interface MonitorBalance { + currency: string + balance: number +} + +/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */ +export interface MonitorQuotaSnapshot { + /** usage | cn_quota | cn_balance */ + source: string + success: boolean + tiers?: MonitorQuotaTier[] + balance?: number | null + balances?: MonitorBalance[] + currency?: string + plan_level?: string + /** 401/403 鉴权失败标记(推导为 failed 状态) */ + credential_invalid?: boolean + error?: string + fetched_at: string +} export interface ChannelMonitor { id: number @@ -47,6 +94,12 @@ export interface ChannelMonitor { extra_headers: Record body_override_mode: BodyOverrideMode body_override: Record | null + /** 检测模式:probe(默认)/ quota / quota_probe */ + check_mode: CheckMode + /** 配额模式关联的账号 ID;探活模式为 null */ + account_id: number | null + /** 主模型最近一次配额快照(配额模式;无历史时为 null) */ + latest_quota?: MonitorQuotaSnapshot | null } export interface ExtraModelStatus { @@ -75,8 +128,14 @@ export interface CreateParams { name: string provider: Provider api_mode?: APIMode + /** 探活模式必填(base origin);quota 模式可留空 */ endpoint: string + /** 探活模式必填;quota 模式可留空 */ api_key: string + /** 缺省 probe;antigravity 仅支持 quota */ + check_mode?: CheckMode + /** 配额模式必填:数据源账号(provider 需与账号平台一致) */ + account_id?: number | null primary_model: string extra_models?: string[] group_name?: string @@ -101,6 +160,8 @@ export interface CheckResult { ping_latency_ms: number | null message: string checked_at: string + /** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */ + quota?: MonitorQuotaSnapshot | null } export interface RunNowResponse { @@ -115,6 +176,8 @@ export interface HistoryItem { ping_latency_ms: number | null message: string checked_at: string + /** 配额快照(配额模式行;探活行为空) */ + quota?: MonitorQuotaSnapshot | null } export interface HistoryParams { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index c8dbe8fb78..f5f19918a8 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -718,6 +718,7 @@ export interface SystemSettings { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -1016,6 +1017,7 @@ export interface UpdateSettingsRequest { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds?: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; diff --git a/frontend/src/api/channelMonitor.ts b/frontend/src/api/channelMonitor.ts index 38dd0c99a8..68076db77d 100644 --- a/frontend/src/api/channelMonitor.ts +++ b/frontend/src/api/channelMonitor.ts @@ -4,7 +4,7 @@ */ import { apiClient } from './client' -import type { Provider, MonitorStatus } from './admin/channelMonitor' +import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor' export type { Provider, MonitorStatus } from './admin/channelMonitor' @@ -33,6 +33,11 @@ export interface UserMonitorView { availability_7d: number extra_models: UserMonitorExtraModel[] timeline: MonitorTimelinePoint[] + /** + * 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时 + * 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。 + */ + latest_quota?: MonitorQuotaSnapshot | null } export interface UserMonitorListResponse { diff --git a/frontend/src/components/admin/monitor/MonitorFiltersBar.vue b/frontend/src/components/admin/monitor/MonitorFiltersBar.vue index 544238f49c..d79e374dce 100644 --- a/frontend/src/components/admin/monitor/MonitorFiltersBar.vue +++ b/frontend/src/components/admin/monitor/MonitorFiltersBar.vue @@ -71,6 +71,10 @@ import { PROVIDER_ANTHROPIC, PROVIDER_GEMINI, PROVIDER_GROK, + PROVIDER_ANTIGRAVITY, + PROVIDER_KIMI, + PROVIDER_ZHIPU, + PROVIDER_DEEPSEEK, } from '@/constants/channelMonitor' defineProps<{ @@ -96,6 +100,10 @@ const providerFilterOptions = computed(() => [ { value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') }, { value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') }, { value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') }, + { value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') }, + { value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') }, + { value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') }, + { value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') }, ]) const enabledFilterOptions = computed(() => [ diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue index 14a9e2dd15..b67bcb775a 100644 --- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue +++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue @@ -11,6 +11,27 @@ + +
+ +
+ +
+
+
@@ -30,7 +51,23 @@
-
+ +
+ + @@ -58,7 +95,7 @@
-
+
@@ -77,7 +114,7 @@

{{ editing.api_key_masked }}

-
+
-
+
- -
+ +
{{ t('admin.channelMonitor.advanced.section') }} @@ -197,6 +234,7 @@ import type { ChannelMonitor, CreateParams, APIMode, + CheckMode, Provider, UpdateParams, } from '@/api/admin/channelMonitor' @@ -216,10 +254,20 @@ import { PROVIDER_ANTHROPIC, PROVIDER_GEMINI, PROVIDER_GROK, + PROVIDER_ANTIGRAVITY, + PROVIDER_KIMI, + PROVIDER_ZHIPU, + PROVIDER_DEEPSEEK, API_MODE_CHAT_COMPLETIONS, API_MODE_RESPONSES, + CHECK_MODE_PROBE, + CHECK_MODE_QUOTA, + CHECK_MODE_QUOTA_PROBE, DEFAULT_GROK_ENDPOINT, DEFAULT_GROK_MODEL, + DEFAULT_KIMI_ENDPOINT, + DEFAULT_ZHIPU_ENDPOINT, + DEFAULT_DEEPSEEK_ENDPOINT, DEFAULT_INTERVAL_SECONDS, } from '@/constants/channelMonitor' @@ -259,6 +307,8 @@ interface MonitorForm { name: string provider: Provider api_mode: APIMode + check_mode: CheckMode + account_id: number | null endpoint: string api_key: string primary_model: string @@ -278,6 +328,8 @@ const form = reactive({ name: '', provider: PROVIDER_ANTHROPIC, api_mode: API_MODE_CHAT_COMPLETIONS, + check_mode: CHECK_MODE_PROBE, + account_id: null, endpoint: '', api_key: '', primary_model: '', @@ -292,6 +344,10 @@ const form = reactive({ body_override: null, }) +// quota / quota_probe 需要关联账号;probe / quota_probe 需要探活字段。 +const usesQuotaMode = computed(() => form.check_mode !== CHECK_MODE_PROBE) +const usesProbePart = computed(() => form.check_mode !== CHECK_MODE_QUOTA) + // jitter 上限与后端校验一致:interval - jitter 不得低于最小检测间隔 15 秒。 const maxJitterSeconds = computed(() => Math.max(0, (form.interval_seconds || 0) - 15)) @@ -402,8 +458,122 @@ const providerOptions = computed(() => [ { value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') }, { value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') }, { value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') }, + { value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') }, + { value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') }, + { value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') }, + { value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') }, ]) +// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。 +const PROVIDER_DEFAULT_ENDPOINTS: Partial> = { + [PROVIDER_KIMI]: DEFAULT_KIMI_ENDPOINT, + [PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT, + [PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT, +} + +interface CheckModeOption { + value: CheckMode + label: string + hint: string + disabled: boolean +} + +const checkModeOptions = computed(() => [ + { + value: CHECK_MODE_PROBE, + label: t('admin.channelMonitor.form.checkModeProbe'), + hint: t('admin.channelMonitor.form.checkModeProbeHint'), + // antigravity 无探活 adapter,仅配额模式。 + disabled: form.provider === PROVIDER_ANTIGRAVITY, + }, + { + value: CHECK_MODE_QUOTA, + label: t('admin.channelMonitor.form.checkModeQuota'), + hint: t('admin.channelMonitor.form.checkModeQuotaHint'), + disabled: false, + }, + { + value: CHECK_MODE_QUOTA_PROBE, + label: t('admin.channelMonitor.form.checkModeQuotaProbe'), + hint: t('admin.channelMonitor.form.checkModeQuotaProbeHint'), + // antigravity 无探活 adapter,只支持配额模式。 + disabled: form.provider === PROVIDER_ANTIGRAVITY, + }, +]) + +function checkModeButtonClass(mode: CheckMode): string { + const active = form.check_mode === mode + if (active) { + return 'border-primary-500 bg-white text-primary-700 shadow-sm dark:border-primary-400 dark:bg-primary-500/15 dark:text-primary-300' + } + return 'border-blue-100 bg-white/70 text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400' +} + +function selectCheckMode(mode: CheckMode) { + if (checkModeOptions.value.find((opt) => opt.value === mode)?.disabled) return + form.check_mode = mode + if (!usesQuotaMode.value) form.account_id = null +} + +// --- 关联账号选择器 --- + +interface LinkedAccount { + id: number + name: string +} + +const linkedAccounts = ref([]) +const accountsLoading = ref(false) + +const accountOptions = computed(() => + linkedAccounts.value.map((a) => ({ + value: String(a.id), + label: `${a.name} (#${a.id})`, + })), +) + +// Select 组件绑定 string,与 number | null 互转。 +const accountSelectValue = computed({ + get: () => (form.account_id == null ? '' : String(form.account_id)), + set: (raw: string) => { + if (raw === '') { + form.account_id = null + return + } + const id = Number(raw) + if (Number.isFinite(id)) form.account_id = id + }, +}) + +// 拉取当前 provider 平台的账号(大分页一次取齐,客户端不再过滤)。 +// 失败不阻塞表单:下拉为空 + 空态提示。 +async function loadLinkedAccounts() { + if (!usesQuotaMode.value || !props.show) return + accountsLoading.value = true + try { + const res = await adminAPI.accounts.list(1, 200, { platform: form.provider }) + linkedAccounts.value = (res.items || []).map((a) => ({ id: a.id, name: a.name })) + // 关联账号必须与 provider 同平台:平台切换后原选择失效,自动清掉。 + if (form.account_id != null && !linkedAccounts.value.some((a) => a.id === form.account_id)) { + form.account_id = null + } + } catch (err: unknown) { + console.warn('load linked accounts failed', err) + linkedAccounts.value = [] + } finally { + accountsLoading.value = false + } +} + +watch( + () => [props.show, form.provider, form.check_mode] as const, + ([show]) => { + if (!show) return + void loadLinkedAccounts() + }, + { immediate: true }, +) + function selectProvider(provider: Provider) { if (form.provider === provider) return const previousProvider = form.provider @@ -411,14 +581,23 @@ function selectProvider(provider: Provider) { previousProvider === PROVIDER_GROK && form.endpoint === DEFAULT_GROK_ENDPOINT const clearGrokModel = previousProvider === PROVIDER_GROK && form.primary_model === DEFAULT_GROK_MODEL + const clearPrevDefaultEndpoint = + !!PROVIDER_DEFAULT_ENDPOINTS[previousProvider] && form.endpoint === PROVIDER_DEFAULT_ENDPOINTS[previousProvider] form.provider = provider + form.account_id = null + // antigravity 仅配额模式:切到它时强制 quota(checkModeOptions 同步禁用其余项)。 + if (provider === PROVIDER_ANTIGRAVITY && form.check_mode !== CHECK_MODE_QUOTA) { + form.check_mode = CHECK_MODE_QUOTA + } if (provider === PROVIDER_GROK) { if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL return } - if (clearGrokEndpoint) form.endpoint = '' + if (clearGrokEndpoint || clearPrevDefaultEndpoint) form.endpoint = '' if (clearGrokModel) form.primary_model = '' + const defaultEndpoint = PROVIDER_DEFAULT_ENDPOINTS[provider] + if (defaultEndpoint && !form.endpoint.trim()) form.endpoint = defaultEndpoint } // Clear api_key whenever provider changes to avoid cross-provider key mismatch. @@ -447,6 +626,8 @@ function resetForm() { form.name = '' form.provider = PROVIDER_ANTHROPIC form.api_mode = API_MODE_CHAT_COMPLETIONS + form.check_mode = CHECK_MODE_PROBE + form.account_id = null form.endpoint = '' form.api_key = '' form.primary_model = '' @@ -467,6 +648,8 @@ function loadFromMonitor(m: ChannelMonitor) { form.name = m.name form.provider = m.provider form.api_mode = normalizeAPIMode(m.api_mode) + form.check_mode = m.check_mode || CHECK_MODE_PROBE + form.account_id = m.account_id ?? null form.endpoint = m.endpoint form.api_key = '' form.primary_model = m.primary_model @@ -533,15 +716,17 @@ function buildPayload(): CreateParams { name: form.name.trim(), provider: form.provider, api_mode: form.provider === PROVIDER_OPENAI ? form.api_mode : API_MODE_CHAT_COMPLETIONS, - endpoint: form.endpoint.trim(), - api_key: form.api_key.trim(), - primary_model: form.primary_model.trim(), - extra_models: form.extra_models, + check_mode: form.check_mode, + account_id: usesQuotaMode.value ? form.account_id : null, + endpoint: usesProbePart.value ? form.endpoint.trim() : '', + api_key: usesProbePart.value ? form.api_key.trim() : '', + primary_model: usesProbePart.value ? form.primary_model.trim() : 'quota', + extra_models: usesProbePart.value ? form.extra_models : [], group_name: form.group_name.trim(), enabled: form.enabled, interval_seconds: form.interval_seconds, jitter_seconds: form.jitter_seconds || 0, - template_id: form.template_id, + template_id: usesProbePart.value ? form.template_id : null, extra_headers: form.extra_headers, body_override_mode: form.body_override_mode, body_override: form.body_override, @@ -554,7 +739,11 @@ async function handleSubmit() { appStore.showError(t('admin.channelMonitor.nameRequired')) return } - if (!form.primary_model.trim()) { + if (usesQuotaMode.value && form.account_id == null) { + appStore.showError(t('admin.channelMonitor.linkedAccountRequired')) + return + } + if (usesProbePart.value && !form.primary_model.trim()) { appStore.showError(t('admin.channelMonitor.primaryModelRequired')) return } @@ -568,7 +757,7 @@ async function handleSubmit() { // Only send api_key if user typed a new value if (api_key) req.api_key = api_key // template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义) - if (form.template_id == null) { + if (usesProbePart.value && form.template_id == null) { req.clear_template = true delete req.template_id } diff --git a/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue b/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue index eccec82848..67797904c7 100644 --- a/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue +++ b/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue @@ -1,7 +1,8 @@