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:
Sakurasan committed 2026-10-17 04:09:39 +08:00
1 parent ad10bdc400
commit c39c3e7da4
16 files changed
+984 -125

No files matched your search

+22
View File
@@ -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
+86 -6
View File
@@ -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)">
+62 -1
View File
@@ -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>