Files
JianFeeeee 8334cffbc9 feat(templates): source templates for multi-key balancing
A template stores every source field except name and api_key, so
operators spin up N key-bearing sources from one shared skeleton
instead of duplicating the whole source block N times.

- config: SourceTemplate type + RuntimeConfig.SourceTemplates stored
  in runtime.json alongside runtime sources
- store: UpsertTemplate / ListTemplates / RemoveTemplate
- core: Templates / SaveTemplate / RemoveTemplate
- gateway: GET/POST/DELETE /api/source_templates
- webui: source list gains a Templates button opening a manager with
  per-template edit/delete; the add-source dialog gains 'from
  template' (event-delegated picker) and 'as template' (card modal)
  buttons in its header; z-index fixed so the template editor layers
  above the manager
2026-08-27 12:09:38 +08:00

296 lines
9.8 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)
}
// handleSourceTemplatesAPI manages reusable source templates (a Source minus
// name and api_key) stored in the runtime file so the WebUI can spin up
// multiple key-bearing sources from one shared template.
func (g *Gateway) handleSourceTemplatesAPI(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/source_templates")
path = strings.Trim(path, "/")
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"templates": g.core.Templates()})
case http.MethodPost:
var t config.SourceTemplate
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.core.SaveTemplate(t); err != nil {
writeError(w, http.StatusBadRequest, "template_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "templates": g.core.Templates()})
case http.MethodDelete:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "template name required")
return
}
if err := g.core.RemoveTemplate(path); err != nil {
writeError(w, http.StatusBadRequest, "template_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// 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)
}