Files
openteam/web/src/views/admin/ChannelsView.vue
T
Sakurasan c39c3e7da4 feat: 补齐上游细节、渠道观测与模型定价元数据
- 上游 429 的 Retry-After 转成渠道冷却时长(夹在 10 分钟内;比默认值短则不缩短,
  否则 1s 就把坏渠道放回轮转)。失败计数改由 proxyOne 就地报——只有它读得到响应头。
- 故障转移时间预算 OT_PROXY_FAILOVER_BUDGET(默认 30s):只约束第二个及以后的渠道,
  首个不受限(长回答本就要跑几分钟),但坏渠道不再把客户端拖到 N×timeout。
- 流式空闲心跳 OT_PROXY_STREAM_HEARTBEAT(默认 15s):补一行 SSE 注释 `: ping`,
  防止 nginx/Cloudflare 的空闲超时掐掉长思考。心跳与正文共用一把写的锁,
  gin 的 ResponseWriter 不是并发安全的;注释行不进转换也不计费。
- 渠道观测:GET /api/v1/admin/channels/stats?window=5m|1h|6h|24h|7d 聚合请求量、
  成功率、P50/P95/max 延迟、成本与 top_errors;分位只取成功样本,canceled(客户端
  主动断开)不计入渠道错误率。渠道页桌面表格加「近N小时」列、移动端加同内容条,
  顶部可切窗口。
- 修一个让统计瞎掉的归因问题:全部渠道失败时 recordError 传的是 nil,
  usage_logs 里 channel_id=0,这类失败在渠道维度完全看不见。改为归到最后一个
  真正试过的渠道。
- 模型定价与元数据:Model 新增 cache_write_price(0 则回退输入价×1.25,可显式配
  Anthropic 1h 的 ×2)、context_length 与 supports_tools/vision/reasoning;
  finishUsage 走 CacheWritePriceOr()。/v1/models 叠加用户与密钥白名单过滤
  (原来会把限制外的模型名直接泄漏在列表里)并返回元数据,价格不外泄。
- /v1/models 的 Anthropic 分支原是死代码:协议只按路径判,而两种客户端共用这个路径。
  改为按 x-api-key / anthropic-version 头判,OpenAI SDK 从不发这两个头,互不影响。
- NewGateway 的十个位置参数换成 Options 结构体(三个 Duration 两个 int,传错编译期发现不了)。

新增 6 个用例:Retry-After 两种写法与夹顶、重试预算截止、空闲心跳出帧、
/v1/models 过滤与双形状、缓存写价两条路径、失败归因。
2026-10-17 04:09:39 +08:00

469 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack, PhArrowsClockwise } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
import { fmtCost } from '@/lib/format'
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel, ChannelStat } from '@/types'
const toast = useToastStore()
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Channel | null>(null)
const saving = ref(false)
const busyId = ref<number | null>(null)
const expandedId = ref<number | null>(null)
// 近况:按渠道聚合的成功率/延迟分位/成本,来自 usage_logs
const statWindows = [
{ v: '5m', label: '5 分钟' },
{ v: '1h', label: '1 小时' },
{ v: '6h', label: '6 小时' },
{ v: '24h', label: '24 小时' },
{ v: '7d', label: '7 天' },
]
const statWindow = ref('1h')
const stats = ref<Record<number, ChannelStat>>({})
// 渠道 + 近况合成一份列表,两套布局共用(也省掉模板里的非空断言)
const rows = computed(() => channels.value.map((ch) => ({ ch, st: stats.value[ch.id] ?? null })))
function successRate(s: ChannelStat): string {
return `${((1 - s.err_rate) * 100).toFixed(s.err_rate > 0 ? 1 : 0)}%`
}
// 分位延迟取自成功样本,没有样本时显示 -(0ms 是真实值,不能也显示成 -)
function fmtMs(n: number, has: boolean): string {
if (!has) return '-'
return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}s` : `${n}ms`
}
function toggleDrawer(ch: Channel) {
expandedId.value = expandedId.value === ch.id ? null : ch.id
}
const form = reactive({
name: '',
formats: ['chat'] as string[],
base_url: '',
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
api_key: '',
weight: 1,
priority: 0,
timeout_ms: 300000,
max_concurrency: 16,
enabled: true,
})
async function load() {
try {
const { data } = await http.get('/admin/channels')
channels.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
await loadStats()
}
async function loadStats() {
try {
const { data } = await http.get('/admin/channels/stats', { params: { window: statWindow.value } })
const map: Record<number, ChannelStat> = {}
for (const s of data.data.items as ChannelStat[]) map[s.channel_id] = s
stats.value = map
} catch (e) {
toast.err(errMsg(e))
}
}
async function switchWindow(v: string) {
if (statWindow.value === v) return
statWindow.value = v
await loadStats()
}
function openCreate() {
editing.value = null
Object.assign(form, {
name: '', formats: ['chat'], base_url: '',
base_urls: { chat: '', responses: '', messages: '' },
api_key: '',
weight: 1, priority: 0, timeout_ms: 300000, max_concurrency: 16, enabled: true,
})
editOpen.value = true
}
function openEdit(ch: Channel) {
editing.value = ch
Object.assign(form, {
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
base_url: ch.base_url,
base_urls: {
chat: ch.base_urls?.chat ?? '',
responses: ch.base_urls?.responses ?? '',
messages: ch.base_urls?.messages ?? '',
},
api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
})
editOpen.value = true
}
async function save() {
if (form.formats.length === 0) {
toast.err('请至少选择一种 API 格式')
return
}
saving.value = true
const payload = {
...form,
weight: Number(form.weight),
priority: Number(form.priority),
timeout_ms: Number(form.timeout_ms),
max_concurrency: Number(form.max_concurrency),
}
try {
if (editing.value) {
await http.put(`/admin/channels/${editing.value.id}`, payload)
toast.ok('渠道已更新')
} else {
await http.post('/admin/channels', payload)
toast.ok('渠道已创建')
}
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
async function remove(ch: Channel) {
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
try {
await http.delete(`/admin/channels/${ch.id}`)
toast.ok('渠道已删除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function testChannel(ch: Channel) {
busyId.value = ch.id
try {
await http.post(`/admin/channels/${ch.id}/test`)
toast.ok(`渠道 ${ch.name} 连接正常`)
} catch (e) {
toast.err(`连接失败: ${errMsg(e)}`)
} finally {
busyId.value = null
await load()
}
}
// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示
function runtimeHint(ch: Channel): string {
if (ch.cooldown_seconds > 0) return `冷却中 ${ch.cooldown_seconds}s`
if (ch.runtime_fails > 0) return `连续失败 ${ch.runtime_fails}/3`
return ''
}
function needRecover(ch: Channel): boolean {
return ch.cooldown_seconds > 0 || ch.health_status !== 'healthy'
}
async function recoverChannel(ch: Channel) {
busyId.value = ch.id
try {
await http.post(`/admin/channels/${ch.id}/recover`)
toast.ok(`渠道 ${ch.name} 已恢复`)
} catch (e) {
toast.err(errMsg(e))
} finally {
busyId.value = null
await load()
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex overflow-hidden rounded-md border border-edge2" role="group" aria-label="近况统计窗口">
<button
v-for="w in statWindows"
:key="w.v"
class="px-2 py-1.5 text-[11px] transition"
:class="statWindow === w.v ? 'bg-accent text-white' : 'bg-surface text-muted hover:text-ink'"
@click="switchWindow(w.v)"
>{{ w.label }}</button>
</div>
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
</div>
</div>
<!-- 移动端:卡片列表 -->
<div class="space-y-3 md:hidden">
<div v-for="{ ch, st } in rows" :key="ch.id" class="card p-4" :class="ch.enabled ? 'bg-accent-soft' : ''">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
<div class="mt-1.5 flex flex-wrap gap-1">
<code
v-for="f in ch.formats || []"
:key="f"
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
>{{ protocolShort(f) }}</code>
</div>
</div>
<div class="flex shrink-0 gap-1.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
<Badge v-if="runtimeHint(ch)" :variant="ch.cooldown_seconds > 0 ? 'err' : 'warn'">
{{ runtimeHint(ch) }}
</Badge>
</div>
</div>
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
<div v-if="st" class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted">
<span class="mono-num">{{ st.requests }} 次</span>
<span class="mono-num" :class="st.err_rate > 0.05 ? 'text-err' : ''">成功 {{ successRate(st) }}</span>
<span class="mono-num">P50 {{ fmtMs(st.p50_ms, st.success > 0) }}</span>
<span class="mono-num">P95 {{ fmtMs(st.p95_ms, st.success > 0) }}</span>
<span class="mono-num">{{ fmtCost(st.cost) }}</span>
<span
v-for="e in st.top_errors"
:key="e.code"
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-err"
>{{ e.code }}×{{ e.count }}</span>
</div>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-edge pt-3">
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
<PhPulse :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button
v-if="needRecover(ch)"
class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong"
:disabled="busyId === ch.id"
@click="recoverChannel(ch)"
>
<PhArrowsClockwise :size="13" />
立即恢复
</button>
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
<PhStack :size="13" />
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
<PhNotePencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
<PhTrash :size="13" />
删除
</button>
</div>
<div v-if="expandedId === ch.id" class="mt-3 border-t border-edge pt-3">
<ChannelModelsDrawer :channel="ch" />
</div>
</div>
<p v-if="channels.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
还没有渠道,点击「添加渠道」
</p>
</div>
<!-- 桌面端:表格 -->
<div class="card hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[920px]">
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
<th scope="col" class="px-4 py-2.5 font-medium">近{{ statWindows.find((w) => w.v === statWindow)?.label }}</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<template v-for="{ ch, st } in rows" :key="ch.id">
<tr class="table-row" :style="ch.enabled ? { borderLeft: '2px solid var(--color-accent)' } : {}">
<td class="px-4 py-2.5">
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
<span class="truncate">{{ ch.name }}</span>
<PhCaretDown :size="12" class="shrink-0 text-muted transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
</button>
</td>
<td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5">
<code
v-for="f in ch.formats || []"
:key="f"
class="font-mono text-[11px] leading-4 text-muted"
>{{ protocolShort(f) }}</code>
</div>
</td>
<td class="max-w-[220px] truncate px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
<div
v-if="runtimeHint(ch)"
class="mt-1 font-mono text-[11px]"
:class="ch.cooldown_seconds > 0 ? 'text-err' : 'text-warn'"
>{{ runtimeHint(ch) }}</div>
</td>
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5">
<div v-if="st" class="flex flex-col gap-0.5 text-[11px] leading-4 text-muted">
<span class="mono-num">
{{ st.requests }} 次 ·
<span :class="st.err_rate > 0.05 ? 'text-err' : ''">成功 {{ successRate(st) }}</span> ·
P95 {{ fmtMs(st.p95_ms, st.success > 0) }} · {{ fmtCost(st.cost) }}
</span>
<span v-if="st.top_errors.length" class="font-mono text-[10px] text-err">
{{ st.top_errors.map((e) => `${e.code}×${e.count}`).join(' ') }}
</span>
</div>
<span v-else class="text-[11px] text-muted">-</span>
</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
<PhPulse :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button
v-if="needRecover(ch)"
class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong"
:disabled="busyId === ch.id"
@click="recoverChannel(ch)"
>
<PhArrowsClockwise :size="13" />
立即恢复
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
<PhNotePencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
<PhTrash :size="13" />
删除
</button>
</div>
</td>
</tr>
<tr v-if="expandedId === ch.id" class="bg-surface/40">
<td colspan="7" class="px-4 py-3">
<ChannelModelsDrawer :channel="ch" />
</td>
</tr>
</template>
<tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<div>
<span class="mb-1.5 block text-xs font-medium text-muted">支持的 API 格式</span>
<div class="flex flex-wrap gap-2">
<label
v-for="opt in PROTOCOL_OPTIONS"
:key="opt.value"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="form.formats.includes(opt.value) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 rounded accent-[var(--color-accent)]"
/>
{{ opt.label }}
</label>
</div>
<p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input
v-model="form.base_url"
label="Base URL(可选)"
placeholder="https://api.openai.com/v1"
:maxlength="255"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
/>
<div class="space-y-3 rounded-md border border-edge p-3">
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" :maxlength="255" />
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
</div>
<Input
v-model="form.api_key"
label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'"
/>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input v-model="form.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
</div>
<div class="flex items-center justify-between rounded-md border border-edge p-3">
<div>
<p class="text-sm font-medium text-ink">启用渠道</p>
<p class="text-xs text-muted">禁用后该渠道不会被用于请求转发</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.enabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="form.enabled ? 'bg-accent' : 'bg-surface2'"
@click="form.enabled = !form.enabled"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="form.enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>