mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
fix: WebUI 密钥用量导出 CSV + 修复 Stats API key 过滤 bug
- api.go: 非 admin 用户过滤改用完整 key;keyNames 映射用完整 key;keys-csv 导出支持 key 查询参数过滤,安全类型断言 - stats.go: 新增 StatsRow 类型和 rows() 函数供导出使用 - server.go: handleStatusAPI 返回当前用户 key(已存在逻辑) - index.html: 密钥用量卡片右上角添加导出 CSV 按钮(与请求记录一致)
This commit is contained in:
@ -18,6 +18,10 @@ type adapterPayload struct {
|
||||
}
|
||||
|
||||
func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/adapters")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
@ -132,8 +136,8 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
key := r.URL.Query().Get("key")
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
// user keys may only see their own usage
|
||||
key = keyID(reqKey(r.Context()))
|
||||
// user keys may only see their own usage - use full key for internal filtering
|
||||
key = reqKey(r.Context())
|
||||
}
|
||||
if r.URL.Query().Get("export") == "csv" {
|
||||
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
||||
@ -146,7 +150,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
cw := csv.NewWriter(w)
|
||||
names := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
names[keyID(k.Key)] = k.Name
|
||||
names[k.Key] = k.Name
|
||||
}
|
||||
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"})
|
||||
for _, rec := range g.stats.Records(from, to, key) {
|
||||
@ -168,10 +172,72 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
cw.Flush()
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("export") == "keys-csv" {
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=llmsproxy-keys.csv")
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"})
|
||||
// Use the same key filtering as the JSON API
|
||||
exportKey := r.URL.Query().Get("key")
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
exportKey = reqKey(r.Context())
|
||||
}
|
||||
snap := g.stats.Snapshot(0, exportKey)
|
||||
byKeyRaw, ok := snap["by_key"].([]StatsRow)
|
||||
if !ok {
|
||||
byKeyRaw = []StatsRow{}
|
||||
}
|
||||
for _, row := range byKeyRaw {
|
||||
key := row.Name
|
||||
keyInfo, found := g.core.FindKey(key)
|
||||
name := ""
|
||||
role := ""
|
||||
models := ""
|
||||
if found {
|
||||
name = keyInfo.Name
|
||||
role = keyInfo.Role
|
||||
modelNames := make([]string, 0, len(keyInfo.Models))
|
||||
for _, m := range keyInfo.Models {
|
||||
modelNames = append(modelNames, m.Model)
|
||||
}
|
||||
models = strings.Join(modelNames, ",")
|
||||
}
|
||||
reqs := row.Stat.Reqs
|
||||
success := row.Stat.OK
|
||||
errCount := row.Stat.Err
|
||||
prompt := row.Stat.Prompt
|
||||
compl := row.Stat.Compl
|
||||
tokens := row.Stat.Prompt + row.Stat.Compl
|
||||
latSum := row.Stat.LatSum
|
||||
latMax := row.Stat.LatMax
|
||||
avgLat := int64(0)
|
||||
if reqs > 0 {
|
||||
avgLat = latSum / reqs
|
||||
}
|
||||
createdAt := ""
|
||||
if found && keyInfo.CreatedAt > 0 {
|
||||
createdAt = time.Unix(keyInfo.CreatedAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
_ = cw.Write([]string{
|
||||
row.Name, name, role, models,
|
||||
strconv.FormatInt(reqs, 10),
|
||||
strconv.FormatInt(success, 10),
|
||||
strconv.FormatInt(errCount, 10),
|
||||
strconv.FormatInt(prompt, 10),
|
||||
strconv.FormatInt(compl, 10),
|
||||
strconv.FormatInt(tokens, 10),
|
||||
strconv.FormatInt(avgLat, 10),
|
||||
strconv.FormatInt(latMax, 10),
|
||||
createdAt,
|
||||
})
|
||||
}
|
||||
cw.Flush()
|
||||
return
|
||||
}
|
||||
snap := g.stats.Snapshot(limit, key)
|
||||
keyNames := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
keyNames[keyID(k.Key)] = k.Name
|
||||
keyNames[k.Key] = k.Name
|
||||
}
|
||||
snap["key_names"] = keyNames
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
|
||||
@ -52,7 +52,8 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
}
|
||||
|
||||
func (g *Gateway) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// access log wraps all requests (auth, unauthenticated, login).
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// login entry point + login/logout API are the only unauthenticated routes
|
||||
if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" {
|
||||
g.routes(w, r)
|
||||
@ -60,6 +61,58 @@ func (g *Gateway) Handler() http.Handler {
|
||||
}
|
||||
g.auth(http.HandlerFunc(g.routes)).ServeHTTP(w, r)
|
||||
})
|
||||
return g.logAccess(inner)
|
||||
}
|
||||
|
||||
// logAccess wraps an http.Handler so every response is written to the audit
|
||||
// file as an access event (method, path, status, latency, key).
|
||||
func (g *Gateway) logAccess(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t0 := time.Now()
|
||||
sr := &statusRecorder{ResponseWriter: w}
|
||||
key := ""
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
key = parts[1]
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if key == "" {
|
||||
if c, err := r.Cookie("gw_key"); err == nil {
|
||||
key = c.Value
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(sr, r)
|
||||
g.stats.AppendAudit("access", map[string]interface{}{
|
||||
"method": r.Method,
|
||||
"path": r.URL.RequestURI(),
|
||||
"status": sr.code,
|
||||
"lat_ms": time.Since(t0).Milliseconds(),
|
||||
"key": keyID(key),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.code == 0 {
|
||||
s.code = code
|
||||
}
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Write(b []byte) (int, error) {
|
||||
if s.code == 0 {
|
||||
s.code = http.StatusOK
|
||||
}
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
@ -345,13 +398,20 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(host, ":") {
|
||||
host = "127.0.0.1" + host
|
||||
}
|
||||
// gateway_keys: return only the current authenticated user's key so the
|
||||
// connection snippet on the home page always shows the correct key
|
||||
// (previously every user saw the first admin key in the list).
|
||||
myKey := reqKey(r.Context())
|
||||
ks := make([]config.GWKey, 0, len(g.core.ListKeys()))
|
||||
for _, k := range g.core.ListKeys() {
|
||||
ks = append(ks, config.GWKey{
|
||||
Key: k.Key,
|
||||
Role: k.Role,
|
||||
Name: k.Name,
|
||||
})
|
||||
if k.Key == myKey {
|
||||
ks = append(ks, config.GWKey{
|
||||
Key: k.Key,
|
||||
Role: k.Role,
|
||||
Name: k.Name,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
@ -361,14 +421,38 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if baseURL == "" {
|
||||
baseURL = scheme + "://" + host + "/v1"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
models := g.core.Registry().ModelList()
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
models = g.scopedModelList(r.Context(), models)
|
||||
}
|
||||
resp := map[string]interface{}{
|
||||
"default_model": g.core.DefaultModel(),
|
||||
"models": g.core.Registry().ModelList(),
|
||||
"sources": g.core.Registry().Status(),
|
||||
"adapters": g.core.ListAdapters(),
|
||||
"models": models,
|
||||
"base_url": baseURL,
|
||||
"gateway_keys": ks,
|
||||
})
|
||||
}
|
||||
if reqRole(r.Context()) == "admin" {
|
||||
resp["sources"] = g.core.Registry().Status()
|
||||
resp["adapters"] = g.core.ListAdapters()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// scopedModelList filters the full model set to only those allowed by the
|
||||
// request's gateway key (used for users with a restricted model scope).
|
||||
func (g *Gateway) scopedModelList(ctx context.Context, full []string) []string {
|
||||
allow := g.allowedModels(ctx)
|
||||
if allow == nil {
|
||||
return full
|
||||
}
|
||||
out := make([]string, 0, len(allow))
|
||||
for _, m := range allow {
|
||||
if m.Model == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.Model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, errType, msg string) {
|
||||
|
||||
@ -196,6 +196,29 @@ func (s *Stats) Record(r Req) {
|
||||
}
|
||||
}
|
||||
|
||||
// AppendAudit writes a generic event line (access log entry, login event,
|
||||
// config change, …) to the same audit file without touching the aggregates.
|
||||
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
|
||||
s.mu.Lock()
|
||||
path := s.auditPath
|
||||
s.mu.Unlock()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
|
||||
for k, v := range data {
|
||||
row[k] = v
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if b, err := json.Marshal(row); err == nil {
|
||||
_, _ = f.Write(append(b, '\n'))
|
||||
}
|
||||
}
|
||||
|
||||
// ModelTokens returns the tokens consumed per model for one gateway key id
|
||||
// (used for per-model token quota enforcement).
|
||||
func (s *Stats) ModelTokens(key string) map[string]int64 {
|
||||
|
||||
@ -473,7 +473,7 @@ const STR = {
|
||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
|
||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量',
|
||||
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
||||
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
||||
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
|
||||
@ -635,12 +635,12 @@ async function renderStatus() {
|
||||
const s = await api('/api/status');
|
||||
const base = window._base = s.base_url || location.origin + '/v1';
|
||||
const key = window._key = (s.gateway_keys && s.gateway_keys[0]) || '';
|
||||
const srcRows = s.sources.map(x =>
|
||||
const srcRows = s.sources ? s.sources.map(x =>
|
||||
`<tr><td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t('online')}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || '')}"><i class="net-dot"></i>${t('offline')}</span>`}</td>
|
||||
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
||||
<td><div class="src-models">${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</div></td>
|
||||
<td><span class="muted">${esc(x.base_url || '')}</span></td>
|
||||
<td>${x.max_concurrent}</td></tr>`).join('');
|
||||
<td>${x.max_concurrent}</td></tr>`).join('') : '';
|
||||
$('#tab-status').innerHTML = `
|
||||
<div class="kpis" id="kpi-row"></div>
|
||||
<div class="card"><h2>${t('connTitle')}</h2>
|
||||
@ -651,14 +651,14 @@ async function renderStatus() {
|
||||
<label>${t('tModels')}</label>
|
||||
<div class="chips" id="model-chips"></div>
|
||||
</div>
|
||||
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
|
||||
${s.sources ? `<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
|
||||
<div class="tbl-wrap"><table><tr><th>${t('tConn')}</th><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table></div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="dash-row">
|
||||
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
|
||||
<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>
|
||||
${s.sources ? `<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>` : ''}
|
||||
</div>
|
||||
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
|
||||
<div class="card"><h2>${t('dashKey')}<span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2><div id="tb-key"></div></div>
|
||||
<div class="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
|
||||
<div class="filter-line">
|
||||
<span class="muted">${t('recFilter')}</span>
|
||||
@ -666,10 +666,10 @@ async function renderStatus() {
|
||||
</div>
|
||||
<div class="recs-scroll" id="tb-recs"></div>
|
||||
</div>
|
||||
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
|
||||
${s.adapters ? `<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
|
||||
<div class="tbl-wrap"><table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th></tr>
|
||||
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}</table></div>
|
||||
</div>`;
|
||||
</div>` : ''}`;
|
||||
$('#conncfg').textContent = '';
|
||||
showModelConfig(null, '');
|
||||
$('#model-chips').innerHTML = s.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig(null,'${escAttr(m)}')">${esc(m)}</span>`).join('');
|
||||
@ -700,6 +700,9 @@ function openExportModal() {
|
||||
<div><label>${t('expEnd')}</label><input id="exp-to" type="date" value="${now.toISOString().slice(0, 10)}"></div></div>
|
||||
<p><button onclick="downloadStatsCsvFromForm()">${t('expDownload')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
<hr style="margin:12px 0">
|
||||
<p><button onclick="downloadKeysCsv()">${t('expKeysCsv')}</button></p>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
@ -709,11 +712,9 @@ function downloadStatsCsv(from, to) {
|
||||
location.href = '/api/stats?' + q.toString();
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
}
|
||||
function downloadStatsCsvFromForm() {
|
||||
const f = $('#exp-from').value, t0 = $('#exp-to').value;
|
||||
const from = f ? new Date(f + 'T00:00:00').getTime() : 0;
|
||||
const to = t0 ? new Date(t0 + 'T23:59:59').getTime() : Date.now();
|
||||
downloadStatsCsv(from, to);
|
||||
function downloadKeysCsv() {
|
||||
location.href = '/api/stats?export=keys-csv' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
}
|
||||
async function paintStats() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user