mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
- Req: add CacheHit and CacheMiss fields (carrying upstream cache accounting from either prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens) - recordChatUsage (non-streaming): copy cache fields from resp.TokenUsage - pumpStream (streaming): write lastUsage cache fields back onto rec at stream end, so streaming requests carry cache data too - CSV export: add first_byte_ms, cache_hit_tokens, cache_miss_tokens columns alongside the existing latency/prompt/completion - WebUI request-records table: add a Cache column showing hit% per row (green/amber tag with tooltip hit/miss breakdown; em-dash when the upstream reported no cache data)
257 lines
8.3 KiB
Go
257 lines
8.3 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"llmsproxy/internal/config"
|
|
)
|
|
|
|
type adapterPayload struct {
|
|
Name string `json:"name"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
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, "/")
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if path == "" {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": g.core.ListAdapters()})
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "adapter code not exposed; edit in UI")
|
|
case http.MethodPost:
|
|
var p adapterPayload
|
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := g.core.UploadAdapter(p.Name, p.Code); err != nil {
|
|
writeError(w, http.StatusBadRequest, "adapter_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": p.Name})
|
|
case http.MethodDelete:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "adapter name required")
|
|
return
|
|
}
|
|
if err := g.core.RemoveAdapter(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "adapter_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// sourcePayload mirrors config.Source for JSON web UI editing.
|
|
type sourcePayload struct {
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Adapter string `json:"adapter"`
|
|
Endpoint string `json:"endpoint"`
|
|
ImageEndpoint string `json:"image_endpoint"`
|
|
Models []config.Model `json:"models"`
|
|
Headers map[string]string `json:"headers"`
|
|
Meta map[string]interface{} `json:"meta"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
RPM int `json:"rpm"` // optional requests-per-minute cap, 0 = unlimited
|
|
}
|
|
|
|
func (g *Gateway) handleSourcesAPI(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/sources")
|
|
path = strings.Trim(path, "/")
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.core.Sources()})
|
|
case http.MethodPost:
|
|
var p sourcePayload
|
|
body, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
src := config.Source{
|
|
Name: p.Name,
|
|
BaseURL: p.BaseURL,
|
|
APIKey: p.APIKey,
|
|
Adapter: p.Adapter,
|
|
Endpoint: p.Endpoint,
|
|
ImageEndpoint: p.ImageEndpoint,
|
|
Models: p.Models,
|
|
Headers: p.Headers,
|
|
Meta: p.Meta,
|
|
Temperature: p.Temperature,
|
|
MaxTokens: p.MaxTokens,
|
|
MaxConcurrent: p.MaxConcurrent,
|
|
RPM: p.RPM,
|
|
}
|
|
if err := g.core.AddSource(src); err != nil {
|
|
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
case http.MethodDelete:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "source name required")
|
|
return
|
|
}
|
|
if err := g.core.RemoveSource(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// csvHeaders stamps the shared download headers for CSV exports.
|
|
func csvHeaders(w http.ResponseWriter, filename string) {
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
|
}
|
|
|
|
// exportKey resolves which masked key id an export covers: admins may pass
|
|
// any key filter, user keys are always scoped to themselves (records and
|
|
// aggregates are keyed by the masked keyID form).
|
|
func exportKey(r *http.Request) string {
|
|
k := r.URL.Query().Get("key")
|
|
if reqRole(r.Context()) != "admin" {
|
|
k = keyID(reqKey(r.Context()))
|
|
}
|
|
return k
|
|
}
|
|
|
|
// handleStatsAPI returns per-key / per-model / per-source usage aggregates and
|
|
// the recent request audit trail.
|
|
func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
limit := 500
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20000 {
|
|
limit = n
|
|
}
|
|
}
|
|
key := exportKey(r)
|
|
if r.URL.Query().Get("export") == "csv" {
|
|
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
|
to, _ := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
|
|
if to == 0 {
|
|
to = time.Now().UnixMilli()
|
|
}
|
|
csvHeaders(w, "llmsproxy-requests.csv")
|
|
cw := csv.NewWriter(w)
|
|
names := map[string]string{}
|
|
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", "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),
|
|
rec.Key,
|
|
names[rec.Key],
|
|
rec.Type,
|
|
rec.Model,
|
|
rec.Source,
|
|
strconv.Itoa(rec.Status),
|
|
strconv.FormatBool(rec.OK),
|
|
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,
|
|
})
|
|
}
|
|
cw.Flush()
|
|
return
|
|
}
|
|
if r.URL.Query().Get("export") == "keys-csv" {
|
|
csvHeaders(w, "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"})
|
|
keyFilter := exportKey(r)
|
|
// by_key rows are keyed by the masked id (keyID); build a masked-id ->
|
|
// record map so names/roles/models resolve for the export.
|
|
byMasked := map[string]config.GWKey{}
|
|
for _, k := range g.core.ListKeys() {
|
|
byMasked[keyID(k.Key)] = k
|
|
}
|
|
snap := g.stats.Snapshot(0, keyFilter)
|
|
byKeyRaw, ok := snap["by_key"].([]StatsRow)
|
|
if !ok {
|
|
byKeyRaw = []StatsRow{}
|
|
}
|
|
for _, row := range byKeyRaw {
|
|
keyInfo, found := byMasked[row.Name]
|
|
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, ",")
|
|
}
|
|
avgLat := int64(0)
|
|
if row.Stat.Reqs > 0 {
|
|
avgLat = row.Stat.LatSum / row.Stat.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(row.Stat.Reqs, 10),
|
|
strconv.FormatInt(row.Stat.OK, 10),
|
|
strconv.FormatInt(row.Stat.Err, 10),
|
|
strconv.FormatInt(row.Stat.Prompt, 10),
|
|
strconv.FormatInt(row.Stat.Compl, 10),
|
|
strconv.FormatInt(row.Stat.Prompt+row.Stat.Compl, 10),
|
|
strconv.FormatInt(avgLat, 10),
|
|
strconv.FormatInt(row.Stat.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
|
|
}
|
|
snap["key_names"] = keyNames
|
|
writeJSON(w, http.StatusOK, snap)
|
|
}
|