diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 417f396..360ba11 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -171,7 +171,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { for _, k := range g.core.ListKeys() { names[keyID(k.Key)] = k.Name } - _ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"}) + _ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "first_byte_ms", "cache_hit_tokens", "cache_miss_tokens", "error"}) for _, rec := range g.stats.AuditRecords(from, to, key) { _ = cw.Write([]string{ time.UnixMilli(rec.Time).Format(time.RFC3339), @@ -185,6 +185,9 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { strconv.FormatInt(rec.Prompt, 10), strconv.FormatInt(rec.Compl, 10), strconv.FormatInt(rec.LatMs, 10), + strconv.FormatInt(rec.FirstByteMs, 10), + strconv.FormatInt(rec.CacheHit, 10), + strconv.FormatInt(rec.CacheMiss, 10), rec.Err, }) } diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 429d0df..8407da4 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -570,6 +570,17 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon if rec.Compl == 0 { rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls) } + // Cache accounting: whichever source format the adapter normalized into + // (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens), + // read it back so the request record carries the hit/miss split. + if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 { + rec.CacheHit = int64(d.CachedTokens) + } else if resp.TokenUsage.PromptCacheHit > 0 { + rec.CacheHit = int64(resp.TokenUsage.PromptCacheHit) + } + if resp.TokenUsage.PromptCacheMiss > 0 { + rec.CacheMiss = int64(resp.TokenUsage.PromptCacheMiss) + } } // writeChatCompletion renders a unified response as an OpenAI @@ -748,6 +759,16 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type var tut *types.TokenUsage if lastUsage != nil { tut = lastUsage + // Write the upstream's cache accounting back onto the request record + // so the audit trail carries the hit/miss split for streaming too. + if d := tut.PromptTokensDetails; d != nil && d.CachedTokens > 0 { + rec.CacheHit = int64(d.CachedTokens) + } else if tut.PromptCacheHit > 0 { + rec.CacheHit = int64(tut.PromptCacheHit) + } + if tut.PromptCacheMiss > 0 { + rec.CacheMiss = int64(tut.PromptCacheMiss) + } } else if rec.Prompt+rec.Compl > 0 { u := types.TokenUsage{ Prompt: int(rec.Prompt), diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 789e833..2cef29e 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -30,6 +30,11 @@ type Req struct { // to the first SSE chunk sent to the client); for non-streaming it // equals LatMs. 0 when unmeasured (legacy records). FirstByteMs int64 `json:"first_byte_ms,omitempty"` + // CacheHit / CacheMiss carry the upstream prompt-cache accounting + // (DeepSeek-style hit/miss tokens) when the upstream reports it. + // Both 0 = upstream gave no cache data. + CacheHit int64 `json:"cache_hit_tokens,omitempty"` + CacheMiss int64 `json:"cache_miss_tokens,omitempty"` OK bool `json:"ok"` // Status http status code Status int `json:"status"` diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 8ffb2ef..52c1b87 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -1971,18 +1971,30 @@ const slice = records.slice().reverse().slice(0, 300); el.innerHTML = ` - ` + + ` + slice .map( (r) => ` - `, + + + `, ) .join("") + "
${t("thTime")}${t("thStatus")}${t("thKey")}${t("thType")}${t("thModel")}${t("thSrc")}${t("thPrompt")}${t("thCompl")}${t("thLatMs")}
${t("thPrompt")}${t("thCompl")}${t("thCache") || "缓存"}${t("thLatMs")}
${fmtTime(r.time)} ${r.ok ? `${r.status || 200}` : `${r.status || 500}`} ${esc(keyNames[r.key] ? keyNames[r.key] + " · " + r.key : r.key)}${esc(r.type)}${esc(r.model)}${esc(r.source || "")}${fmtTok(r.prompt_tokens)}${fmtTok(r.completion_tokens)}${fmtMs(r.latency_ms)}
${fmtTok(r.prompt_tokens)}${fmtTok(r.completion_tokens)}${cacheCell(r)}${fmtMs(r.latency_ms)}
"; } + + // cacheCell renders the per-request cache-hit column: a percentage when + // the upstream reported cache accounting, otherwise an em dash. + function cacheCell(r) { + const hit = r.cache_hit_tokens || 0; + if (!hit && !(r.cache_miss_tokens > 0)) return ''; + const prompt = r.prompt_tokens || 0; + const pct = prompt > 0 ? Math.round((hit * 100) / prompt) : 0; + return ` 0 ? "tag-amber" : ""}" title="命中 ${hit} / 未命中 ${r.cache_miss_tokens || 0}">${pct}%`; + } function showModelConfig(srcName, model) { const el = $("#conncfg"); if (!el) return;