渠道支持多 API 格式 + 协议全名展示
- Channel 新增 formats(jsonb: chat|responses|messages), 一个渠道可同时声明 支持 OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages - 路由改为按渠道声明的 formats 决定直通/转换: 客户端协议在 formats 内直通, 否则转换为其首选支持格式(chat > messages > responses) - 兼容旧数据: formats 为空时按 provider 推断(openai→chat+responses, anthropic→messages, compatible→chat) - 前端: 渠道表/表单展示 API 格式(支持多选), usage 明细显示协议全名 (OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
masked = "****"
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "base_url": ch.BaseURL,
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "base_url": ch.BaseURL,
|
||||
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
|
||||
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
||||
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
||||
@@ -44,21 +44,52 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
}
|
||||
|
||||
type channelBody struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages,空则按 provider 推断
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func validateProvider(p string) bool {
|
||||
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
|
||||
}
|
||||
|
||||
var validFormats = map[string]bool{
|
||||
store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true,
|
||||
}
|
||||
|
||||
// resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。
|
||||
func resolveFormats(provider string, formats []string) ([]string, error) {
|
||||
if len(formats) == 0 {
|
||||
switch provider {
|
||||
case store.ChannelProviderAnthropic:
|
||||
return []string{store.FormatMessages}, nil
|
||||
case store.ChannelProviderOpenAI:
|
||||
return []string{store.FormatChat, store.FormatResponses}, nil
|
||||
default:
|
||||
return []string{store.FormatChat}, nil
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(formats))
|
||||
for _, f := range formats {
|
||||
if !validFormats[f] {
|
||||
return nil, fmt.Errorf("unsupported format %q", f)
|
||||
}
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminCreateChannel POST /api/v1/admin/channels
|
||||
func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
var req channelBody
|
||||
@@ -74,13 +105,18 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, "api_key required")
|
||||
return
|
||||
}
|
||||
formats, err := resolveFormats(req.Provider, req.Formats)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
enc, err := h.a.Enc.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
|
||||
return
|
||||
}
|
||||
ch := store.Channel{
|
||||
Name: req.Name, Provider: req.Provider, BaseURL: strings.TrimRight(req.BaseURL, "/"),
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: strings.TrimRight(req.BaseURL, "/"),
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||
@@ -100,16 +136,17 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
@@ -160,11 +197,30 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
if body.Enabled != nil {
|
||||
updates["enabled"] = *body.Enabled
|
||||
}
|
||||
if body.Formats != nil {
|
||||
prov := ch.Provider
|
||||
if body.Provider != nil {
|
||||
prov = *body.Provider
|
||||
}
|
||||
formats, ferr := resolveFormats(prov, *body.Formats)
|
||||
if ferr != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, ferr.Error())
|
||||
return
|
||||
}
|
||||
updates["formats"] = formats
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
|
||||
return
|
||||
}
|
||||
// jsonb 序列化走模型字段更新
|
||||
if f, ok := updates["formats"]; ok {
|
||||
if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update formats")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ func (a *App) Seed() error {
|
||||
ch := store.Channel{
|
||||
Name: a.Cfg.Proxy.DefaultChannelName,
|
||||
Provider: store.ChannelProviderOpenAI,
|
||||
Formats: []string{store.FormatChat, store.FormatResponses},
|
||||
BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
|
||||
APIKeyEnc: enc,
|
||||
Weight: 1,
|
||||
|
||||
@@ -4,6 +4,7 @@ package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -175,21 +176,6 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 协议分派
|
||||
|
||||
// upstreamProtoFor 根据渠道 provider 与客户端协议确定上游协议与路径。
|
||||
func upstreamProtoFor(provider, clientProto string) string {
|
||||
switch provider {
|
||||
case store.ChannelProviderAnthropic:
|
||||
return convert.ProtoMessages
|
||||
case store.ChannelProviderOpenAI:
|
||||
if clientProto == convert.ProtoMessages {
|
||||
return convert.ProtoChat
|
||||
}
|
||||
return clientProto
|
||||
default: // compatible:假定 OpenAI Chat 形状
|
||||
return convert.ProtoChat
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamPath(proto string) string {
|
||||
switch proto {
|
||||
case convert.ProtoMessages:
|
||||
@@ -209,18 +195,39 @@ type upstreamPlan struct {
|
||||
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
|
||||
}
|
||||
|
||||
// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。
|
||||
func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) {
|
||||
up := upstreamProtoFor(provider, clientProto)
|
||||
plan := &upstreamPlan{path: upstreamPath(up), body: body}
|
||||
if up != clientProto {
|
||||
converted, err := convert.ConvertRequest(body, clientProto, up)
|
||||
// conversionTarget 决定客户端协议在渠道上的处理方式:
|
||||
// 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。
|
||||
func conversionTarget(formats []string, clientProto string) string {
|
||||
for _, f := range formats {
|
||||
if f == clientProto {
|
||||
return clientProto
|
||||
}
|
||||
}
|
||||
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
|
||||
for _, f := range formats {
|
||||
if f == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换。
|
||||
func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstreamPlan, error) {
|
||||
target := conversionTarget(ch.FormatsEffective(), clientProto)
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
|
||||
}
|
||||
plan := &upstreamPlan{path: upstreamPath(target), body: body}
|
||||
if target != clientProto {
|
||||
converted, err := convert.ConvertRequest(body, clientProto, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.body = converted
|
||||
plan.lineConv = convert.NewStreamTransformer(up, clientProto)
|
||||
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) }
|
||||
plan.lineConv = convert.NewStreamTransformer(target, clientProto)
|
||||
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto st
|
||||
var lastStatus = http.StatusBadGateway
|
||||
var lastBody = []byte("all upstream channels failed")
|
||||
for _, ch := range cands {
|
||||
plan, err := prepareUpstream(ch.Provider, clientProto, body)
|
||||
plan, err := prepareUpstream(ch, clientProto, body)
|
||||
if err != nil {
|
||||
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
|
||||
continue
|
||||
|
||||
@@ -22,6 +22,11 @@ const (
|
||||
ChannelHealthDegraded = "degraded"
|
||||
ChannelHealthCooldown = "cooldown"
|
||||
|
||||
// 渠道原生支持的协议格式
|
||||
FormatChat = "chat" // OpenAI Chat Completions
|
||||
FormatResponses = "responses" // OpenAI Responses API
|
||||
FormatMessages = "messages" // Anthropic Messages
|
||||
|
||||
UsageStatusSuccess = "success"
|
||||
UsageStatusError = "error"
|
||||
UsageStatusCanceled = "canceled"
|
||||
@@ -74,7 +79,8 @@ type APIKey struct {
|
||||
type Channel struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
||||
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible
|
||||
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
|
||||
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
||||
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||
@@ -87,6 +93,21 @@ type Channel struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FormatsEffective 返回渠道实际支持的原生协议;未显式配置时按 provider 推断。
|
||||
func (c *Channel) FormatsEffective() []string {
|
||||
if len(c.Formats) > 0 {
|
||||
return c.Formats
|
||||
}
|
||||
switch c.Provider {
|
||||
case ChannelProviderAnthropic:
|
||||
return []string{FormatMessages}
|
||||
case ChannelProviderOpenAI:
|
||||
return []string{FormatChat, FormatResponses}
|
||||
default: // compatible
|
||||
return []string{FormatChat}
|
||||
}
|
||||
}
|
||||
|
||||
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
|
||||
type Model struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// 协议格式显示名与选项
|
||||
export const PROTOCOL_NAMES: Record<string, string> = {
|
||||
chat: 'OpenAI Chat Completions',
|
||||
responses: 'OpenAI Responses API',
|
||||
messages: 'Anthropic Messages',
|
||||
}
|
||||
|
||||
export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'chat', label: 'OpenAI Chat Completions' },
|
||||
{ value: 'responses', label: 'OpenAI Responses API' },
|
||||
{ value: 'messages', label: 'Anthropic Messages' },
|
||||
]
|
||||
|
||||
export function protocolName(p: string): string {
|
||||
return PROTOCOL_NAMES[p] ?? p
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
provider: 'openai' | 'anthropic' | 'compatible'
|
||||
formats: string[] // chat | responses | messages
|
||||
base_url: string
|
||||
api_key_masked: string
|
||||
weight: number
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { PROTOCOL_OPTIONS, protocolName } from '@/lib/protocol'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
@@ -18,6 +19,7 @@ const busyId = ref<number | null>(null)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider: 'openai' as 'openai' | 'anthropic' | 'compatible',
|
||||
formats: [] as string[],
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
@@ -27,10 +29,15 @@ const form = reactive({
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const providerMap: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
compatible: '兼容',
|
||||
// 按供应商推断默认支持的协议格式
|
||||
function defaultFormats(p: string): string[] {
|
||||
if (p === 'anthropic') return ['messages']
|
||||
if (p === 'openai') return ['chat', 'responses']
|
||||
return ['chat']
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
form.formats = defaultFormats(form.provider)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -45,7 +52,7 @@ async function load() {
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', provider: 'openai', base_url: '', api_key: '',
|
||||
name: '', provider: 'openai', formats: defaultFormats('openai'), base_url: '', api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
@@ -54,7 +61,8 @@ function openCreate() {
|
||||
function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
Object.assign(form, {
|
||||
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '',
|
||||
name: ch.name, provider: ch.provider, formats: [...(ch.formats || defaultFormats(ch.provider))],
|
||||
base_url: ch.base_url, api_key: '',
|
||||
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||
})
|
||||
@@ -142,7 +150,7 @@ onMounted(load)
|
||||
<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">类型</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>
|
||||
@@ -153,7 +161,15 @@ onMounted(load)
|
||||
<tbody>
|
||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ providerMap[ch.provider] }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="f in ch.formats || []"
|
||||
:key="f"
|
||||
class="rounded-full bg-surface2 px-2 py-0.5 text-[10px] leading-4 text-muted"
|
||||
>{{ protocolName(f) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="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">
|
||||
@@ -186,14 +202,38 @@ onMounted(load)
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.name" label="名称" placeholder="openai" />
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">API 类型</span>
|
||||
<select v-model="form.provider" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">供应商</span>
|
||||
<select
|
||||
v-model="form.provider"
|
||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
|
||||
@change="onProviderChange"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">兼容</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<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" />
|
||||
<Input
|
||||
v-model="form.api_key"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import { protocolName } from '@/lib/protocol'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
@@ -122,7 +123,7 @@ onMounted(load)
|
||||
<tbody>
|
||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.protocol }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
|
||||
|
||||
Reference in New Issue
Block a user