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 过滤与双形状、缓存写价两条路径、失败归因。
This commit is contained in:
1 parent
ad10bdc400
commit
c39c3e7da4
16 files changed
+984
-125
No files matched your search
@@ -64,6 +64,11 @@ export interface Model {
|
||||
input_price: number
|
||||
output_price: number
|
||||
cache_read_price: number
|
||||
cache_write_price: number // 0 = 按输入价 ×1.25 计
|
||||
context_length: number // 0 = 未填
|
||||
supports_tools: boolean
|
||||
supports_vision: boolean
|
||||
supports_reasoning: boolean
|
||||
enabled: boolean
|
||||
sort: number
|
||||
channels: ModelBinding[]
|
||||
@@ -72,6 +77,23 @@ export interface Model {
|
||||
denied?: boolean
|
||||
}
|
||||
|
||||
export interface ChannelStat {
|
||||
channel_id: number
|
||||
channel_name: string
|
||||
requests: number
|
||||
success: number
|
||||
error: number
|
||||
canceled: number
|
||||
err_rate: number
|
||||
p50_ms: number
|
||||
p95_ms: number
|
||||
max_ms: number
|
||||
tokens: number
|
||||
cost: number
|
||||
top_errors: { code: string; count: number }[]
|
||||
last_at: string
|
||||
}
|
||||
|
||||
export interface ModelSummary {
|
||||
total: number
|
||||
unpriced: number
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
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 } from '@/types'
|
||||
import type { Channel, ChannelStat } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const channels = ref<Channel[]>([])
|
||||
@@ -19,6 +20,30 @@ 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
|
||||
}
|
||||
@@ -43,6 +68,24 @@ async function load() {
|
||||
} 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() {
|
||||
@@ -161,12 +204,23 @@ onMounted(load)
|
||||
<h1 class="text-lg font-semibold">渠道</h1>
|
||||
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
||||
</div>
|
||||
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
|
||||
<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 in channels" :key="ch.id" class="card p-4" :class="ch.enabled ? 'bg-accent-soft' : ''">
|
||||
<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>
|
||||
@@ -189,6 +243,18 @@ onMounted(load)
|
||||
</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" />
|
||||
@@ -228,7 +294,7 @@ onMounted(load)
|
||||
<!-- 桌面端:表格 -->
|
||||
<div class="card hidden md:block">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<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>
|
||||
@@ -237,11 +303,12 @@ onMounted(load)
|
||||
<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 in channels" :key="ch.id">
|
||||
<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)">
|
||||
@@ -271,6 +338,19 @@ onMounted(load)
|
||||
>{{ 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)">
|
||||
|
||||
@@ -49,9 +49,29 @@ const form = reactive({
|
||||
input_price: 0,
|
||||
output_price: 0,
|
||||
cache_read_price: 0,
|
||||
cache_write_price: 0,
|
||||
context_length: 0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_reasoning: false,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 能力位:网关会原样透传到 /v1/models 的 capabilities,客户端据此决定能不能发图/发工具
|
||||
const capabilityFields = [
|
||||
{ key: 'supports_tools', label: '工具调用' },
|
||||
{ key: 'supports_vision', label: '图片输入' },
|
||||
{ key: 'supports_reasoning', label: '推理' },
|
||||
] as const
|
||||
|
||||
function capsOf(m: Model): string[] {
|
||||
return capabilityFields.filter((f) => m[f.key]).map((f) => f.label)
|
||||
}
|
||||
|
||||
function fmtCtx(n: number): string {
|
||||
return n >= 1000000 ? `${(n / 1000000).toFixed(n % 1000000 ? 1 : 0)}M` : `${Math.round(n / 1000)}K`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get('/admin/models')
|
||||
@@ -64,7 +84,11 @@ async function load() {
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
||||
Object.assign(form, {
|
||||
name: '', input_price: 0, output_price: 0, cache_read_price: 0, cache_write_price: 0,
|
||||
context_length: 0, supports_tools: false, supports_vision: false, supports_reasoning: false,
|
||||
enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
@@ -73,6 +97,9 @@ function openEdit(m: Model) {
|
||||
Object.assign(form, {
|
||||
name: m.name,
|
||||
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
|
||||
cache_write_price: m.cache_write_price ?? 0, context_length: m.context_length ?? 0,
|
||||
supports_tools: !!m.supports_tools, supports_vision: !!m.supports_vision,
|
||||
supports_reasoning: !!m.supports_reasoning,
|
||||
enabled: m.enabled,
|
||||
})
|
||||
editOpen.value = true
|
||||
@@ -84,6 +111,11 @@ async function save() {
|
||||
input_price: Number(form.input_price),
|
||||
output_price: Number(form.output_price),
|
||||
cache_read_price: Number(form.cache_read_price),
|
||||
cache_write_price: Number(form.cache_write_price),
|
||||
context_length: Number(form.context_length),
|
||||
supports_tools: form.supports_tools,
|
||||
supports_vision: form.supports_vision,
|
||||
supports_reasoning: form.supports_reasoning,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
try {
|
||||
@@ -167,11 +199,20 @@ onMounted(load)
|
||||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||||
<Badge v-if="m.denied" variant="err">已禁止</Badge>
|
||||
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
||||
<span v-if="m.context_length" class="mono-num text-[11px] text-muted">{{ fmtCtx(m.context_length) }}</span>
|
||||
<span
|
||||
v-for="cap in capsOf(m)"
|
||||
:key="cap"
|
||||
class="rounded bg-surface2 px-1.5 py-0.5 text-[10px] text-muted"
|
||||
>{{ cap }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">
|
||||
缓存写 {{ m.cache_write_price || '入×1.25' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
||||
@@ -209,6 +250,26 @@ onMounted(load)
|
||||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||||
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
|
||||
<Input
|
||||
v-model="form.cache_write_price"
|
||||
label="缓存写价格 /1M"
|
||||
type="number"
|
||||
hint="留 0 则按输入价 ×1.25 计(Anthropic 5 分钟口径);1 小时缓存填 ×2"
|
||||
/>
|
||||
<Input v-model="form.context_length" label="上下文长度(token)" type="number" hint="0 = 不对外声明" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-muted">能力位(透传到 /v1/models 的 capabilities)</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="f in capabilityFields"
|
||||
:key="f.key"
|
||||
class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-ink"
|
||||
>
|
||||
<input v-model="form[f.key]" type="checkbox" class="size-4 accent-[var(--color-accent)]" />
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
Reference in new issue
Block a user