mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(auto): AUTO-only priority chain with tiered slots + live source probing; CSV export w/ key names; audit persistence; fix prompt token accounting & deepseek thinking
This commit is contained in:
@ -121,9 +121,11 @@ func (c *Config) ApplyDefaults() error {
|
|||||||
|
|
||||||
// RuntimeConfig is the persisted web-UI editable slice (sources added/edited).
|
// RuntimeConfig is the persisted web-UI editable slice (sources added/edited).
|
||||||
type RuntimeConfig struct {
|
type RuntimeConfig struct {
|
||||||
Sources []Source `json:"sources"`
|
Sources []Source `json:"sources"`
|
||||||
Keys []GWKey `json:"keys,omitempty"`
|
DeletedSources []string `json:"deleted_sources,omitempty"`
|
||||||
Auto []ModelScope `json:"auto,omitempty"`
|
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
|
||||||
|
Keys []GWKey `json:"keys,omitempty"`
|
||||||
|
Auto []ModelScope `json:"auto,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GWKey is a gateway API key persisted in the runtime store. Role is "admin"
|
// GWKey is a gateway API key persisted in the runtime store. Role is "admin"
|
||||||
@ -144,6 +146,8 @@ type GWKey struct {
|
|||||||
// uses Hours as the window length in hours.
|
// uses Hours as the window length in hours.
|
||||||
type ModelScope struct {
|
type ModelScope struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
Source string `json:"source,omitempty"` // optional: pin to one upstream source; "" = any source
|
||||||
|
Tier int `json:"tier,omitempty"`
|
||||||
TokenQuota int64 `json:"token_quota"`
|
TokenQuota int64 `json:"token_quota"`
|
||||||
Period string `json:"period,omitempty"`
|
Period string `json:"period,omitempty"`
|
||||||
Hours int64 `json:"hours,omitempty"`
|
Hours int64 `json:"hours,omitempty"`
|
||||||
@ -160,6 +164,8 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error {
|
|||||||
}
|
}
|
||||||
var o struct {
|
var o struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
TokenQuota int64 `json:"token_quota"`
|
TokenQuota int64 `json:"token_quota"`
|
||||||
Period string `json:"period"`
|
Period string `json:"period"`
|
||||||
Hours int64 `json:"hours"`
|
Hours int64 `json:"hours"`
|
||||||
@ -168,6 +174,8 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.Model = o.Model
|
m.Model = o.Model
|
||||||
|
m.Source = o.Source
|
||||||
|
m.Tier = o.Tier
|
||||||
m.TokenQuota = o.TokenQuota
|
m.TokenQuota = o.TokenQuota
|
||||||
m.Period = o.Period
|
m.Period = o.Period
|
||||||
m.Hours = o.Hours
|
m.Hours = o.Hours
|
||||||
|
|||||||
@ -49,14 +49,16 @@ func (s *Store) Upsert(src Source) error {
|
|||||||
for i := range s.data.Sources {
|
for i := range s.data.Sources {
|
||||||
if s.data.Sources[i].Name == src.Name {
|
if s.data.Sources[i].Name == src.Name {
|
||||||
s.data.Sources[i] = src
|
s.data.Sources[i] = src
|
||||||
|
s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name)
|
||||||
return s.persistLocked()
|
return s.persistLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.data.Sources = append(s.data.Sources, src)
|
s.data.Sources = append(s.data.Sources, src)
|
||||||
|
s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name)
|
||||||
return s.persistLocked()
|
return s.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deletes a runtime source and persists.
|
// Remove deletes a runtime source or hides a base YAML source and persists.
|
||||||
func (s *Store) Remove(name string) (bool, error) {
|
func (s *Store) Remove(name string) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -69,11 +71,47 @@ func (s *Store) Remove(name string) (bool, error) {
|
|||||||
}
|
}
|
||||||
kept = append(kept, src)
|
kept = append(kept, src)
|
||||||
}
|
}
|
||||||
if !removed {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
s.data.Sources = kept
|
s.data.Sources = kept
|
||||||
return true, s.persistLocked()
|
if !containsString(s.data.DeletedSources, name) {
|
||||||
|
s.data.DeletedSources = append(s.data.DeletedSources, name)
|
||||||
|
}
|
||||||
|
return removed || containsString(s.data.DeletedSources, name), s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletedSources() map[string]bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, name := range s.data.DeletedSources {
|
||||||
|
out[name] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteAdapter(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !containsString(s.data.DeletedAdapters, name) {
|
||||||
|
s.data.DeletedAdapters = append(s.data.DeletedAdapters, name)
|
||||||
|
}
|
||||||
|
return s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RestoreAdapter(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.data.DeletedAdapters = removeString(s.data.DeletedAdapters, name)
|
||||||
|
return s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeletedAdapters() map[string]bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, name := range s.data.DeletedAdapters {
|
||||||
|
out[name] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) persistLocked() error {
|
func (s *Store) persistLocked() error {
|
||||||
@ -84,6 +122,25 @@ func (s *Store) persistLocked() error {
|
|||||||
return os.WriteFile(s.path, b, 0644)
|
return os.WriteFile(s.path, b, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsString(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeString(list []string, s string) []string {
|
||||||
|
out := list[:0]
|
||||||
|
for _, x := range list {
|
||||||
|
if x != s {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ListKeys returns the persisted gateway keys.
|
// ListKeys returns the persisted gateway keys.
|
||||||
func (s *Store) ListKeys() []GWKey {
|
func (s *Store) ListKeys() []GWKey {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@ -152,6 +152,7 @@ func (c *Core) FindKey(key string) (config.GWKey, bool) { return c.store.KeyByVa
|
|||||||
|
|
||||||
// CreateKey builds a new random gateway key and persists it.
|
// CreateKey builds a new random gateway key and persists it.
|
||||||
func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||||
|
models = cleanScopes(models)
|
||||||
key := make([]byte, 16)
|
key := make([]byte, 16)
|
||||||
if _, err := rand.Read(key); err != nil {
|
if _, err := rand.Read(key); err != nil {
|
||||||
return config.GWKey{}, err
|
return config.GWKey{}, err
|
||||||
@ -185,7 +186,7 @@ func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, not
|
|||||||
if role == "admin" || role == "user" {
|
if role == "admin" || role == "user" {
|
||||||
rec.Role = role
|
rec.Role = role
|
||||||
}
|
}
|
||||||
rec.Models = models
|
rec.Models = cleanScopes(models)
|
||||||
rec.Note = note
|
rec.Note = note
|
||||||
if err := c.store.SaveKey(rec); err != nil {
|
if err := c.store.SaveKey(rec); err != nil {
|
||||||
return config.GWKey{}, err
|
return config.GWKey{}, err
|
||||||
@ -202,16 +203,23 @@ func (c *Core) DeleteKey(key string) (bool, error) { return c.store.DeleteKey(ke
|
|||||||
// highest priority).
|
// highest priority).
|
||||||
func (c *Core) AutoRules() []config.ModelScope { return c.store.AutoRules() }
|
func (c *Core) AutoRules() []config.ModelScope { return c.store.AutoRules() }
|
||||||
|
|
||||||
// SaveAutoRules persists the AUTO scheduling slots.
|
func cleanScopes(entries []config.ModelScope) []config.ModelScope {
|
||||||
func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
|
||||||
clean := make([]config.ModelScope, 0, len(entries))
|
clean := make([]config.ModelScope, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.Model == "" {
|
if e.Model == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if e.Source == "undefined" || e.Source == "null" {
|
||||||
|
e.Source = ""
|
||||||
|
}
|
||||||
clean = append(clean, e)
|
clean = append(clean, e)
|
||||||
}
|
}
|
||||||
return c.store.SaveAutoRules(clean)
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAutoRules persists the AUTO scheduling slots.
|
||||||
|
func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
||||||
|
return c.store.SaveAutoRules(cleanScopes(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry resolves model -> owning provider.
|
// Registry resolves model -> owning provider.
|
||||||
@ -219,14 +227,24 @@ func (c *Core) ProviderForModel(model string) *provider.Provider {
|
|||||||
return c.registry.ProviderForModel(model)
|
return c.registry.ProviderForModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProviderForSlot resolves a (model, source) scheduling slot to a provider;
|
||||||
|
// source "" falls back to ProviderForModel.
|
||||||
|
func (c *Core) ProviderForSlot(model, source string) *provider.Provider {
|
||||||
|
return c.registry.ProviderForSlot(model, source)
|
||||||
|
}
|
||||||
|
|
||||||
// Config exposes the underlying configuration (read-only usage).
|
// Config exposes the underlying configuration (read-only usage).
|
||||||
func (c *Core) Config() *config.Config { return c.cfg }
|
func (c *Core) Config() *config.Config { return c.cfg }
|
||||||
|
|
||||||
// mergedSources = base YAML sources + runtime sources (runtime wins by name).
|
// mergedSources = base YAML sources + runtime sources (runtime wins by name).
|
||||||
func (c *Core) mergedSources() []config.Source {
|
func (c *Core) mergedSources() []config.Source {
|
||||||
|
deleted := c.store.DeletedSources()
|
||||||
byName := map[string]config.Source{}
|
byName := map[string]config.Source{}
|
||||||
order := []string{}
|
order := []string{}
|
||||||
for _, s := range c.cfg.Sources {
|
for _, s := range c.cfg.Sources {
|
||||||
|
if deleted[s.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
byName[s.Name] = s
|
byName[s.Name] = s
|
||||||
order = append(order, s.Name)
|
order = append(order, s.Name)
|
||||||
}
|
}
|
||||||
@ -278,7 +296,17 @@ func (c *Core) Reload() error {
|
|||||||
|
|
||||||
// ---- adapter management (web UI) ----
|
// ---- adapter management (web UI) ----
|
||||||
|
|
||||||
func (c *Core) ListAdapters() []lua.APIAdapter { return c.vm.ListAdapters() }
|
func (c *Core) ListAdapters() []lua.APIAdapter {
|
||||||
|
deleted := c.store.DeletedAdapters()
|
||||||
|
list := c.vm.ListAdapters()
|
||||||
|
out := make([]lua.APIAdapter, 0, len(list))
|
||||||
|
for _, a := range list {
|
||||||
|
if !deleted[a.Name] {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it.
|
// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it.
|
||||||
func (c *Core) UploadAdapter(name, code string) error {
|
func (c *Core) UploadAdapter(name, code string) error {
|
||||||
@ -295,7 +323,7 @@ func (c *Core) UploadAdapter(name, code string) error {
|
|||||||
if err := c.vm.LoadAdapter(path); err != nil {
|
if err := c.vm.LoadAdapter(path); err != nil {
|
||||||
return fmt.Errorf("load adapter: %w", err)
|
return fmt.Errorf("load adapter: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return c.store.RestoreAdapter(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAdapter deletes an adapter script and evicts it from the VM.
|
// RemoveAdapter deletes an adapter script and evicts it from the VM.
|
||||||
@ -303,7 +331,7 @@ func (c *Core) RemoveAdapter(name string) error {
|
|||||||
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
|
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
c.vm.RemoveAdapter(name)
|
c.vm.RemoveAdapter(name)
|
||||||
return nil
|
return c.store.DeleteAdapter(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- source management (web UI) ----
|
// ---- source management (web UI) ----
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"llmsproxy/internal/config"
|
"llmsproxy/internal/config"
|
||||||
)
|
)
|
||||||
@ -133,5 +135,44 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
// user keys may only see their own usage
|
// user keys may only see their own usage
|
||||||
key = keyID(reqKey(r.Context()))
|
key = keyID(reqKey(r.Context()))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, g.stats.Snapshot(limit, key))
|
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()
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=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", "error"})
|
||||||
|
for _, rec := range g.stats.Records(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),
|
||||||
|
rec.Err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,22 +108,37 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid
|
|||||||
|
|
||||||
// filterCandsByModels keeps only providers exposing at least one model of the
|
// filterCandsByModels keeps only providers exposing at least one model of the
|
||||||
// scope (used for user keys with a restricted model scope). An "AUTO" scope
|
// scope (used for user keys with a restricted model scope). An "AUTO" scope
|
||||||
// entry means the key is allowed to use any model.
|
// entry means the key is allowed to use any model. Scope entries with a
|
||||||
|
// Source pinned to a specific upstream narrow the candidates to that source
|
||||||
|
// for the matching model.
|
||||||
func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider {
|
func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider {
|
||||||
allowed := make(map[string]bool, len(allow))
|
|
||||||
for _, m := range allow {
|
for _, m := range allow {
|
||||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
||||||
return cands
|
return cands
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
allowed := make(map[string]bool, len(allow))
|
||||||
|
byModelSrc := map[string]map[string]bool{}
|
||||||
|
for _, m := range allow {
|
||||||
allowed[m.Model] = true
|
allowed[m.Model] = true
|
||||||
|
if m.Source != "" {
|
||||||
|
if byModelSrc[m.Model] == nil {
|
||||||
|
byModelSrc[m.Model] = map[string]bool{}
|
||||||
|
}
|
||||||
|
byModelSrc[m.Model][m.Source] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out := make([]*provider.Provider, 0, len(cands))
|
out := make([]*provider.Provider, 0, len(cands))
|
||||||
for _, p := range cands {
|
for _, p := range cands {
|
||||||
for _, id := range p.Models() {
|
for _, id := range p.Models() {
|
||||||
if allowed[id] {
|
if !allowed[id] {
|
||||||
out = append(out, p)
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
if srcs := byModelSrc[id]; len(srcs) > 0 && !srcs[p.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -197,7 +212,7 @@ func (g *Gateway) scopeTokens(ctx context.Context, sc config.ModelScope) int64 {
|
|||||||
return g.stats.KeyTokens(k)
|
return g.stats.KeyTokens(k)
|
||||||
}
|
}
|
||||||
win := AutoPeriodSeconds(sc.Period, sc.Hours)
|
win := AutoPeriodSeconds(sc.Period, sc.Hours)
|
||||||
return g.stats.WindowTokens(sc.Model, win)
|
return g.stats.WindowTokens(sc.Model, sc.Source, win)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasScopeModel(list []config.ModelScope, s string) bool {
|
func hasScopeModel(list []config.ModelScope, s string) bool {
|
||||||
@ -263,7 +278,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
if model == "" {
|
if model == "" {
|
||||||
model = g.core.DefaultModel()
|
model = g.core.DefaultModel()
|
||||||
}
|
}
|
||||||
if strings.EqualFold(strings.TrimSpace(req.Model), "AUTO") {
|
if isAuto(model) {
|
||||||
plans := g.autoPlans()
|
plans := g.autoPlans()
|
||||||
if len(plans) == 0 {
|
if len(plans) == 0 {
|
||||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot available (quota exhausted or none configured)")
|
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot available (quota exhausted or none configured)")
|
||||||
@ -424,6 +439,39 @@ func effectiveImageModel(model string, cands []*provider.Provider) string {
|
|||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func estimatePromptTokens(req *types.ChatRequest) int64 {
|
||||||
|
if req == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(struct {
|
||||||
|
Messages []types.ChatMessage `json:"messages"`
|
||||||
|
Tools []interface{} `json:"tools,omitempty"`
|
||||||
|
}{Messages: req.Messages, Tools: req.Tools})
|
||||||
|
if len(b) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(len(b)/3 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateTextTokens(parts ...interface{}) int64 {
|
||||||
|
var n int
|
||||||
|
for _, p := range parts {
|
||||||
|
switch v := p.(type) {
|
||||||
|
case string:
|
||||||
|
n += len(v)
|
||||||
|
case json.RawMessage:
|
||||||
|
n += len(v)
|
||||||
|
case []types.ToolCall:
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
n += len(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(n/3 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
||||||
rec.LatMs = 0
|
rec.LatMs = 0
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
@ -440,7 +488,13 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
|
|||||||
rec.OK = true
|
rec.OK = true
|
||||||
rec.Status = http.StatusOK
|
rec.Status = http.StatusOK
|
||||||
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
||||||
|
if rec.Prompt == 0 {
|
||||||
|
rec.Prompt = estimatePromptTokens(req)
|
||||||
|
}
|
||||||
rec.Compl = int64(resp.TokenUsage.Completion)
|
rec.Compl = int64(resp.TokenUsage.Completion)
|
||||||
|
if rec.Compl == 0 {
|
||||||
|
rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls)
|
||||||
|
}
|
||||||
rec.Source = usedSrc
|
rec.Source = usedSrc
|
||||||
rec.Model = usedModel
|
rec.Model = usedModel
|
||||||
g.writeRec(rec)
|
g.writeRec(rec)
|
||||||
@ -495,6 +549,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
|||||||
if usedModel != "" {
|
if usedModel != "" {
|
||||||
rec.Model = usedModel
|
rec.Model = usedModel
|
||||||
}
|
}
|
||||||
|
rec.Prompt = estimatePromptTokens(req)
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
@ -540,7 +595,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
|||||||
choice.FinishReason = &stop
|
choice.FinishReason = &stop
|
||||||
}
|
}
|
||||||
chunk.Choices = []ChunkChoice{choice}
|
chunk.Choices = []ChunkChoice{choice}
|
||||||
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3
|
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3
|
||||||
if !send(chunk) {
|
if !send(chunk) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -575,7 +630,7 @@ func (g *Gateway) autoPlans() []autoPlan {
|
|||||||
}
|
}
|
||||||
plans := make([]autoPlan, 0, len(rules))
|
plans := make([]autoPlan, 0, len(rules))
|
||||||
for _, e := range rules {
|
for _, e := range rules {
|
||||||
p := g.core.ProviderForModel(e.Model)
|
p := g.core.ProviderForSlot(e.Model, e.Source)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -583,7 +638,7 @@ func (g *Gateway) autoPlans() []autoPlan {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
win := AutoPeriodSeconds(e.Period, e.Hours)
|
win := AutoPeriodSeconds(e.Period, e.Hours)
|
||||||
if e.TokenQuota > 0 && g.stats.WindowTokens(e.Model, win) >= e.TokenQuota {
|
if e.TokenQuota > 0 && g.stats.WindowTokens(e.Model, e.Source, win) >= e.TokenQuota {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
plans = append(plans, autoPlan{p: p, model: e.Model, quota: e.TokenQuota, win: win})
|
plans = append(plans, autoPlan{p: p, model: e.Model, quota: e.TokenQuota, win: win})
|
||||||
@ -599,12 +654,14 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
rec.LatMs = 0
|
rec.LatMs = 0
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var lastSrc, lastModel string
|
||||||
for _, pl := range plans {
|
for _, pl := range plans {
|
||||||
if !pl.p.Available() {
|
if !pl.p.Available() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r := *req
|
r := *req
|
||||||
r.Model = pl.model
|
r.Model = pl.model
|
||||||
|
lastSrc, lastModel = pl.p.Name(), pl.model
|
||||||
resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r)
|
resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@ -614,7 +671,13 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
rec.OK = true
|
rec.OK = true
|
||||||
rec.Status = http.StatusOK
|
rec.Status = http.StatusOK
|
||||||
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
||||||
|
if rec.Prompt == 0 {
|
||||||
|
rec.Prompt = estimatePromptTokens(&r)
|
||||||
|
}
|
||||||
rec.Compl = int64(resp.TokenUsage.Completion)
|
rec.Compl = int64(resp.TokenUsage.Completion)
|
||||||
|
if rec.Compl == 0 {
|
||||||
|
rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls)
|
||||||
|
}
|
||||||
rec.Source = usedSrc
|
rec.Source = usedSrc
|
||||||
rec.Model = usedModel
|
rec.Model = usedModel
|
||||||
g.writeRec(rec)
|
g.writeRec(rec)
|
||||||
@ -645,6 +708,12 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
rec.OK = false
|
rec.OK = false
|
||||||
rec.Status = http.StatusBadGateway
|
rec.Status = http.StatusBadGateway
|
||||||
rec.Err = lastErr.Error()
|
rec.Err = lastErr.Error()
|
||||||
|
if rec.Model == "" {
|
||||||
|
rec.Model = lastModel
|
||||||
|
}
|
||||||
|
if rec.Source == "" {
|
||||||
|
rec.Source = lastSrc
|
||||||
|
}
|
||||||
g.writeRec(rec)
|
g.writeRec(rec)
|
||||||
writeError(w, http.StatusBadGateway, "upstream_error", lastErr.Error())
|
writeError(w, http.StatusBadGateway, "upstream_error", lastErr.Error())
|
||||||
}
|
}
|
||||||
@ -687,12 +756,14 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var lastSrc, lastModel string
|
||||||
for _, pl := range plans {
|
for _, pl := range plans {
|
||||||
if !pl.p.Available() {
|
if !pl.p.Available() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r := *req
|
r := *req
|
||||||
r.Model = pl.model
|
r.Model = pl.model
|
||||||
|
lastSrc, lastModel = pl.p.Name(), pl.model
|
||||||
chunks, usedSrc, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r)
|
chunks, usedSrc, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@ -702,6 +773,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
rec.Model = usedModel
|
rec.Model = usedModel
|
||||||
rec.Source = usedSrc
|
rec.Source = usedSrc
|
||||||
}
|
}
|
||||||
|
rec.Prompt = estimatePromptTokens(&r)
|
||||||
for ck := range chunks {
|
for ck := range chunks {
|
||||||
chunk := ChatChunk{
|
chunk := ChatChunk{
|
||||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||||
@ -719,7 +791,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
choice.FinishReason = &stop
|
choice.FinishReason = &stop
|
||||||
}
|
}
|
||||||
chunk.Choices = []ChunkChoice{choice}
|
chunk.Choices = []ChunkChoice{choice}
|
||||||
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3
|
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3
|
||||||
if !send(chunk) {
|
if !send(chunk) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -732,11 +804,14 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
|||||||
rec.OK = false
|
rec.OK = false
|
||||||
rec.Status = http.StatusBadGateway
|
rec.Status = http.StatusBadGateway
|
||||||
rec.Err = lastErr.Error()
|
rec.Err = lastErr.Error()
|
||||||
stop := "stop"
|
if rec.Model == "" {
|
||||||
send(ChatChunk{
|
rec.Model = lastModel
|
||||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: "auto",
|
}
|
||||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
if rec.Source == "" {
|
||||||
})
|
rec.Source = lastSrc
|
||||||
|
}
|
||||||
|
errEvent, _ := json.Marshal(map[string]interface{}{"error": map[string]string{"message": lastErr.Error(), "type": "upstream_error"}})
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", errEvent)
|
||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"llmsproxy/internal/config"
|
"llmsproxy/internal/config"
|
||||||
"llmsproxy/internal/core"
|
"llmsproxy/internal/core"
|
||||||
@ -24,9 +26,11 @@ var uiFS embed.FS
|
|||||||
|
|
||||||
// Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI.
|
// Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI.
|
||||||
type Gateway struct {
|
type Gateway struct {
|
||||||
core *core.Core
|
core *core.Core
|
||||||
ui http.Handler
|
ui http.Handler
|
||||||
stats *Stats
|
stats *Stats
|
||||||
|
probeMu sync.Mutex
|
||||||
|
lastProbe time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||||
@ -34,10 +38,14 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
st := NewStats(3000)
|
||||||
|
if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" {
|
||||||
|
st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl")
|
||||||
|
}
|
||||||
return &Gateway{
|
return &Gateway{
|
||||||
core: c,
|
core: c,
|
||||||
ui: http.FileServer(http.FS(sub)),
|
ui: http.FileServer(http.FS(sub)),
|
||||||
stats: NewStats(3000),
|
stats: st,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,7 +321,21 @@ func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureProbe triggers a live source probe at most once every 30s.
|
||||||
|
func (g *Gateway) ensureProbe(ctx context.Context) {
|
||||||
|
g.probeMu.Lock()
|
||||||
|
due := time.Since(g.lastProbe) > 30*time.Second
|
||||||
|
if due {
|
||||||
|
g.lastProbe = time.Now()
|
||||||
|
}
|
||||||
|
g.probeMu.Unlock()
|
||||||
|
if due {
|
||||||
|
g.core.Registry().ProbeAll(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g.ensureProbe(r.Context())
|
||||||
host := r.Host
|
host := r.Host
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = g.core.Listen()
|
host = g.core.Listen()
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -55,6 +58,7 @@ type Stats struct {
|
|||||||
byKeySrc map[string]map[string]*Stat
|
byKeySrc map[string]map[string]*Stat
|
||||||
recs []Req
|
recs []Req
|
||||||
maxRecs int
|
maxRecs int
|
||||||
|
auditPath string
|
||||||
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
|
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,6 +113,30 @@ func inc(m map[string]*Stat, name string, r Req) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Stats) LoadAudit(path string) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err == nil {
|
||||||
|
var recs []Req
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
var r Req
|
||||||
|
if json.Unmarshal(sc.Bytes(), &r) == nil {
|
||||||
|
recs = append(recs, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if len(recs) > s.maxRecs {
|
||||||
|
recs = recs[len(recs)-s.maxRecs:]
|
||||||
|
}
|
||||||
|
for _, r := range recs {
|
||||||
|
s.Record(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.auditPath = path
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// Record appends a finished request to the aggregates and ring buffer.
|
// Record appends a finished request to the aggregates and ring buffer.
|
||||||
func (s *Stats) Record(r Req) {
|
func (s *Stats) Record(r Req) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@ -128,14 +156,18 @@ func (s *Stats) Record(r Req) {
|
|||||||
s.byKeySrc[r.Key] = ks
|
s.byKeySrc[r.Key] = ks
|
||||||
}
|
}
|
||||||
inc(ks, r.Source, r)
|
inc(ks, r.Source, r)
|
||||||
// window bucket for quota enforcement (per model, per unix hour)
|
// window bucket for quota enforcement (per source-model pair, per unix hour)
|
||||||
tok := r.Prompt + r.Compl
|
tok := r.Prompt + r.Compl
|
||||||
if tok > 0 && r.Model != "" {
|
if tok > 0 && r.Model != "" {
|
||||||
|
key := r.Model
|
||||||
|
if r.Source != "" {
|
||||||
|
key = r.Source + "::" + r.Model
|
||||||
|
}
|
||||||
h := r.Time / hourSec
|
h := r.Time / hourSec
|
||||||
hm := s.modelHour[r.Model]
|
hm := s.modelHour[key]
|
||||||
if hm == nil {
|
if hm == nil {
|
||||||
hm = map[int64]int64{}
|
hm = map[int64]int64{}
|
||||||
s.modelHour[r.Model] = hm
|
s.modelHour[key] = hm
|
||||||
}
|
}
|
||||||
hm[h] += tok
|
hm[h] += tok
|
||||||
if len(hm) > 24*40 {
|
if len(hm) > 24*40 {
|
||||||
@ -150,6 +182,14 @@ func (s *Stats) Record(r Req) {
|
|||||||
if len(s.recs) > s.maxRecs {
|
if len(s.recs) > s.maxRecs {
|
||||||
s.recs = s.recs[len(s.recs)-s.maxRecs:]
|
s.recs = s.recs[len(s.recs)-s.maxRecs:]
|
||||||
}
|
}
|
||||||
|
if s.auditPath != "" {
|
||||||
|
if f, err := os.OpenFile(s.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); err == nil {
|
||||||
|
if b, err := json.Marshal(r); err == nil {
|
||||||
|
_, _ = f.Write(append(b, '\n'))
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelTokens returns the tokens consumed per model for one gateway key id
|
// ModelTokens returns the tokens consumed per model for one gateway key id
|
||||||
@ -197,11 +237,17 @@ func AutoPeriodSeconds(period string, hours int64) int64 {
|
|||||||
|
|
||||||
// WindowTokens returns the tokens billed for the model within the last `sec`
|
// WindowTokens returns the tokens billed for the model within the last `sec`
|
||||||
// seconds (0 = since forever).
|
// seconds (0 = since forever).
|
||||||
func (s *Stats) WindowTokens(model string, sec int64) int64 {
|
// WindowTokens returns the tokens consumed for one model (optionally pinned
|
||||||
|
// to a single source) within the window; sec <= 0 means all time.
|
||||||
|
func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
key := model
|
||||||
|
if source != "" {
|
||||||
|
key = source + "::" + model
|
||||||
|
}
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
hm := s.modelHour[model]
|
hm := s.modelHour[key]
|
||||||
if len(hm) == 0 {
|
if len(hm) == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -240,6 +286,26 @@ type StatsRow struct {
|
|||||||
Stat
|
Stat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Records returns request records filtered by unix-millisecond time range and key.
|
||||||
|
func (s *Stats) Records(from, to int64, key string) []Req {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]Req, 0, len(s.recs))
|
||||||
|
for _, r := range s.recs {
|
||||||
|
if key != "" && r.Key != key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if from > 0 && r.Time < from {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if to > 0 && r.Time > to {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot returns the whole dashboard payload; when key != "" the records
|
// Snapshot returns the whole dashboard payload; when key != "" the records
|
||||||
// and aggregate views are restricted to that gateway key.
|
// and aggregate views are restricted to that gateway key.
|
||||||
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||||
|
|||||||
@ -55,6 +55,7 @@ tr:last-child td { border-bottom:0; }
|
|||||||
.tag-red { background:var(--tag-err); color:var(--err); }
|
.tag-red { background:var(--tag-err); color:var(--err); }
|
||||||
.tag-blue { background:var(--tag-blue); color:var(--accent); }
|
.tag-blue { background:var(--tag-blue); color:var(--accent); }
|
||||||
.tag-warn { background:var(--tag-warn); color:var(--warn); }
|
.tag-warn { background:var(--tag-warn); color:var(--warn); }
|
||||||
|
.net-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:currentColor; margin-right:6px; vertical-align:1px; }
|
||||||
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer;
|
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer;
|
||||||
font:inherit; font-weight:500; transition:background .15s; }
|
font:inherit; font-weight:500; transition:background .15s; }
|
||||||
button:hover { background:var(--accent-h); }
|
button:hover { background:var(--accent-h); }
|
||||||
@ -263,7 +264,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
|||||||
.scr-blocks { display:flex; flex-wrap:wrap; align-items:center; min-width:0; }
|
.scr-blocks { display:flex; flex-wrap:wrap; align-items:center; min-width:0; }
|
||||||
/* 块间嵌合:每个块的凸榫插进左侧块的凹槽(平铺同档模型,如积木搭肩) */
|
/* 块间嵌合:每个块的凸榫插进左侧块的凹槽(平铺同档模型,如积木搭肩) */
|
||||||
.scr-block { position:relative; display:flex; align-items:center; gap:10px; padding:12px 14px 12px 20px;
|
.scr-block { position:relative; display:flex; align-items:center; gap:10px; padding:12px 14px 12px 20px;
|
||||||
color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:7px;
|
color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:0;
|
||||||
border-radius:11px 11px 7px 7px; box-shadow:0 6px 0 rgba(0,0,0,.13),
|
border-radius:11px 11px 7px 7px; box-shadow:0 6px 0 rgba(0,0,0,.13),
|
||||||
inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; }
|
inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; }
|
||||||
/* 左侧凸榫:宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */
|
/* 左侧凸榫:宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */
|
||||||
@ -277,8 +278,9 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
|||||||
.scr-row .scr-block:last-child { margin-right:0; }
|
.scr-row .scr-block:last-child { margin-right:0; }
|
||||||
.scr-row .scr-block:last-child::after { display:none; }
|
.scr-row .scr-block:last-child::after { display:none; }
|
||||||
/* 左右卡扣生长动画(块新获得左凸榫/右凹槽身份时)—— 延迟到位移完成后再弹出 */
|
/* 左右卡扣生长动画(块新获得左凸榫/右凹槽身份时)—— 延迟到位移完成后再弹出 */
|
||||||
|
.scr-block.pre-grow-l::before { transform:translateY(-50%) scaleX(0); transform-origin:100% 50%; opacity:0; }
|
||||||
.scr-block.grow-l::before { animation:growlL .22s ease-out .24s both; }
|
.scr-block.grow-l::before { animation:growlL .22s ease-out .24s both; }
|
||||||
@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; } }
|
@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; opacity:0; } to { transform: translateY(-50%) scaleX(1); opacity:1; } }
|
||||||
.scr-block.grow-r::after { animation:growrR .22s ease-out .24s both; }
|
.scr-block.grow-r::after { animation:growrR .22s ease-out .24s both; }
|
||||||
@keyframes growrR { from { transform: translateY(-50%) scaleX(0); transform-origin:0 50%; } }
|
@keyframes growrR { from { transform: translateY(-50%) scaleX(0); transform-origin:0 50%; } }
|
||||||
/* 上下卡扣生长动画:新行首块的凸榫/凹槽从根部弹出(列位置定后) */
|
/* 上下卡扣生长动画:新行首块的凸榫/凹槽从根部弹出(列位置定后) */
|
||||||
@ -290,7 +292,8 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
|||||||
align-items:center; justify-content:center; font-size:11px; font-weight:800; letter-spacing:.5px;
|
align-items:center; justify-content:center; font-size:11px; font-weight:800; letter-spacing:.5px;
|
||||||
background:rgba(0,0,0,.2); }
|
background:rgba(0,0,0,.2); }
|
||||||
.scr-block .scr-name { white-space:nowrap; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px;
|
.scr-block .scr-name { white-space:nowrap; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px;
|
||||||
max-width:200px; overflow:hidden; text-overflow:ellipsis; }
|
max-width:200px; overflow:hidden; text-overflow:ellipsis; display:flex; flex-direction:column; line-height:1.25; }
|
||||||
|
.scr-block .scr-srcname { font-size:9.5px; opacity:.75; font-weight:600; max-width:200px; overflow:hidden; text-overflow:ellipsis; }
|
||||||
.scr-block .scr-tag { flex:0 0 auto; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10.5px;
|
.scr-block .scr-tag { flex:0 0 auto; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10.5px;
|
||||||
padding:2px 7px; border-radius:9px; background:rgba(0,0,0,.24); color:#ffe9a8;
|
padding:2px 7px; border-radius:9px; background:rgba(0,0,0,.24); color:#ffe9a8;
|
||||||
border:1px solid rgba(255,220,130,.35); cursor:pointer; }
|
border:1px solid rgba(255,220,130,.35); cursor:pointer; }
|
||||||
@ -310,10 +313,12 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
|||||||
.scr-lane.scr-top .scr-block .scr-knob,
|
.scr-lane.scr-top .scr-block .scr-knob,
|
||||||
.scr-lane.scr-last .scr-block .scr-slot { display:none; }
|
.scr-lane.scr-last .scr-block .scr-slot { display:none; }
|
||||||
.scr-block.drag-src { opacity:.28; }
|
.scr-block.drag-src { opacity:.28; }
|
||||||
.scr-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot { visibility:hidden; }
|
.scr-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot,
|
||||||
|
.scr-block.drag-src::before, .scr-block.drag-src::after { visibility:hidden; }
|
||||||
.scr-ghost { position:fixed; z-index:200; pointer-events:none; transform:rotate(1.5deg) scale(1.05);
|
.scr-ghost { position:fixed; z-index:200; pointer-events:none; transform:rotate(1.5deg) scale(1.05);
|
||||||
filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; }
|
filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; }
|
||||||
.scr-ghost .scr-knob, .scr-ghost .scr-slot { display:none; }
|
.scr-ghost .scr-knob, .scr-ghost .scr-slot,
|
||||||
|
.scr-ghost::before, .scr-ghost::after { display:none; }
|
||||||
.scr-gap { height:7px; margin:-2px 0 0; border-radius:8px; border:2px dashed transparent; transition:all .12s;
|
.scr-gap { height:7px; margin:-2px 0 0; border-radius:8px; border:2px dashed transparent; transition:all .12s;
|
||||||
display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px;
|
display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px;
|
||||||
letter-spacing:2px; }
|
letter-spacing:2px; }
|
||||||
@ -407,7 +412,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
|||||||
const STR = {
|
const STR = {
|
||||||
zh: {
|
zh: {
|
||||||
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
|
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
|
||||||
navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
|
navStatus:'状态', navChat:'对话', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
|
||||||
keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。',
|
keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。',
|
||||||
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注(可选)', kCreateBtn:'创建',
|
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注(可选)', kCreateBtn:'创建',
|
||||||
kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除',
|
kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除',
|
||||||
@ -419,11 +424,11 @@ const STR = {
|
|||||||
kAll:'不限',
|
kAll:'不限',
|
||||||
kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动',
|
kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动',
|
||||||
kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除',
|
kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除',
|
||||||
kFormTitle:'模型与 Token 配额', kModelB:'模型', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限',
|
kFormTitle:'模型与 Token 配额', kModelB:'模型', kSrcHint:'同一模型多源时请选择具体来源', kAnySrc:'任意源', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限',
|
||||||
kPeriodB:'重置周期', kPerNothing:'不限(永不过期)', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数',
|
kPeriodB:'重置周期', kPerNothing:'不限(永不过期)', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数',
|
||||||
kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除(从链中移除)',
|
kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除(从链中移除)',
|
||||||
kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'(暂无模型 —— 点击 + 添加)', kAddB:'添加模型',
|
kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'(暂无模型 —— 点击 + 添加)', kAddB:'添加模型',
|
||||||
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级自动选择可用源;点击任一模型可生成固定到该模型的配置。',
|
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级页面的 AUTO 链选择可用源;点击任一模型可生成固定到该模型的配置。',
|
||||||
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
|
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
|
||||||
srcTitle:'源状态', srcCount:'共 %d 个',
|
srcTitle:'源状态', srcCount:'共 %d 个',
|
||||||
tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发',
|
tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发',
|
||||||
@ -440,7 +445,7 @@ const STR = {
|
|||||||
modalNew:'新增源', modalEdit:'编辑源',
|
modalNew:'新增源', modalEdit:'编辑源',
|
||||||
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
|
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
|
||||||
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
|
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
|
||||||
mModels:'模型列表(优先级越大,AUTO 越优先选择)', mAddModel:'+ 模型',
|
mModels:'模型列表', mAddModel:'+ 模型',
|
||||||
mMeta:'Meta(透传给 build_headers 钩子,JSON)', mSave:'保存', mCancel:'取消',
|
mMeta:'Meta(透传给 build_headers 钩子,JSON)', mSave:'保存', mCancel:'取消',
|
||||||
toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载',
|
toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载',
|
||||||
toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s',
|
toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s',
|
||||||
@ -452,7 +457,7 @@ const STR = {
|
|||||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)',
|
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载',
|
||||||
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
||||||
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
||||||
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
|
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
|
||||||
@ -472,11 +477,11 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
|||||||
kAll:'All',
|
kAll:'All',
|
||||||
kBrickH:'Click to edit model & quota · right-click for more · drag to move to another key',
|
kBrickH:'Click to edit model & quota · right-click for more · drag to move to another key',
|
||||||
kCopyB:'Copy', kEditB:'Edit', kDelB:'Delete',
|
kCopyB:'Copy', kEditB:'Edit', kDelB:'Delete',
|
||||||
kFormTitle:'Model & token quota', kModelB:'Model', kQuotaB:'Token quota', kQuotaHintB:'0 / empty = unlimited',
|
kFormTitle:'Model & token quota', kModelB:'Model', kSrcHint:'pick a source when the same model exists on several sources', kAnySrc:'any source', kQuotaB:'Token quota', kQuotaHintB:'0 / empty = unlimited',
|
||||||
kPeriodB:'Reset period', kPerNothing:'Never', kPerHour:'Every hour', kPerWeek:'Every week', kPerMonth:'Every month', kPerHours:'Every N hours', kPerNHint:'hours',
|
kPeriodB:'Reset period', kPerNothing:'Never', kPerHour:'Every hour', kPerWeek:'Every week', kPerMonth:'Every month', kPerHours:'Every N hours', kPerNHint:'hours',
|
||||||
kEditQ:'Edit quota', kClearQ:'Clear quota', kDupB:'Duplicate slot', kDelB2:'Delete (remove from chain)',
|
kEditQ:'Edit quota', kClearQ:'Clear quota', kDupB:'Duplicate slot', kDelB2:'Delete (remove from chain)',
|
||||||
kDupOK:'Copied', kMovOK:'Moved', kSaved:'Saved', kEmptyB:'(no models yet — click + to add)', kAddB:'Add model',
|
kDupOK:'Copied', kMovOK:'Moved', kSaved:'Saved', kEmptyB:'(no models yet — click + to add)', kAddB:'Add model',
|
||||||
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
|
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — follows the AUTO chain from the Priority page. Click a model to pin it.',
|
||||||
copyCfg:'Copy config', copyEnv:'Copy as env vars',
|
copyCfg:'Copy config', copyEnv:'Copy as env vars',
|
||||||
srcTitle:'Sources', srcCount:'%d total',
|
srcTitle:'Sources', srcCount:'%d total',
|
||||||
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
|
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
|
||||||
@ -493,19 +498,19 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
|||||||
modalNew:'Add source', modalEdit:'Edit source',
|
modalNew:'Add source', modalEdit:'Edit source',
|
||||||
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)',
|
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)',
|
||||||
mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature',
|
mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature',
|
||||||
mModels:'Models (higher priority → preferred)', mAddModel:'+ model',
|
mModels:'Models', mAddModel:'+ model',
|
||||||
mMeta:'Meta (passed to build_headers hook, JSON)', mSave:'Save', mCancel:'Cancel',
|
mMeta:'Meta (passed to build_headers hook, JSON)', mSave:'Save', mCancel:'Cancel',
|
||||||
toastCopied:'Copied', toastCopyFail:'Copy failed', toastSaved:'Saved & hot-reloaded',
|
toastCopied:'Copied', toastCopyFail:'Copy failed', toastSaved:'Saved & hot-reloaded',
|
||||||
toastEmpty:'Enter a message', toastBadJson:'Meta is not valid JSON', toastSaveFail:'Save failed: %s',
|
toastEmpty:'Enter a message', toastBadJson:'Meta is not valid JSON', toastSaveFail:'Save failed: %s',
|
||||||
toastUploaded:'Adapter loaded', toastUpFail:'Upload failed: %s', toastNeedAll:'Adapter name and code required',
|
toastUploaded:'Adapter loaded', toastUpFail:'Upload failed: %s', toastNeedAll:'Adapter name and code required',
|
||||||
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
|
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
|
||||||
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
|
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
|
||||||
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
|
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" follows the Priority page AUTO chain',
|
||||||
sortTitle:'Canvas sorting: drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier (or reorder within it), drop into the gap between rows = move up/down a tier. Image models (kind=image) stay out.', sortDragGrip:'grab the handle to drag',
|
sortTitle:'Canvas sorting: drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier (or reorder within it), drop into the gap between rows = move up/down a tier. Image models (kind=image) stay out.', sortDragGrip:'grab the handle to drag',
|
||||||
sortSave:'Save order', sortReset:'Reset', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect',
|
sortSave:'Save order', sortReset:'Reset', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect',
|
||||||
sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
|
sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
|
||||||
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
|
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
|
||||||
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records (audit)',
|
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download',
|
||||||
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
|
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
|
||||||
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
|
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
|
||||||
thTime:'Time', thType:'Type', thStatus:'Status', thLatMs:'Latency',
|
thTime:'Time', thType:'Type', thStatus:'Status', thLatMs:'Latency',
|
||||||
@ -589,7 +594,7 @@ async function renderStatus() {
|
|||||||
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
||||||
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
|
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
|
||||||
<td><span class="muted">${esc(x.base_url || '')}</span></td>
|
<td><span class="muted">${esc(x.base_url || '')}</span></td>
|
||||||
<td>${x.available ? `<span class="tag tag-green">${t('online')}</span>` : `<span class="tag tag-red">${t('offline')}</span>`}</td>
|
<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>${x.max_concurrent}</td></tr>`).join('');
|
<td>${x.max_concurrent}</td></tr>`).join('');
|
||||||
$('#tab-status').innerHTML = `
|
$('#tab-status').innerHTML = `
|
||||||
<div class="kpis" id="kpi-row"></div>
|
<div class="kpis" id="kpi-row"></div>
|
||||||
@ -601,21 +606,21 @@ async function renderStatus() {
|
|||||||
<label>${t('tModels')}</label>
|
<label>${t('tModels')}</label>
|
||||||
<div class="chips" id="model-chips"></div>
|
<div class="chips" id="model-chips"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
|
||||||
|
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
|
||||||
|
</div>
|
||||||
<div class="dash-row">
|
<div class="dash-row">
|
||||||
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
|
<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>
|
<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
|
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
|
||||||
<div class="card"><h2>${t('dashRecs')}</h2>
|
<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">
|
<div class="filter-line">
|
||||||
<span class="muted">${t('recFilter')}</span>
|
<span class="muted">${t('recFilter')}</span>
|
||||||
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
|
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="recs-scroll" id="tb-recs"></div>
|
<div class="recs-scroll" id="tb-recs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
|
|
||||||
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
|
|
||||||
</div>
|
|
||||||
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
|
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
|
||||||
<table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th></tr>
|
<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>
|
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}</table>
|
||||||
@ -636,6 +641,35 @@ function renderKeySelect(keys) {
|
|||||||
if (cur) sel.value = cur;
|
if (cur) sel.value = cur;
|
||||||
}
|
}
|
||||||
function renderKeyF(v) { statsKeyF = v; paintStats(); }
|
function renderKeyF(v) { statsKeyF = v; paintStats(); }
|
||||||
|
function openExportModal() {
|
||||||
|
const now = new Date();
|
||||||
|
const ago = d => { const x = new Date(now); x.setDate(x.getDate() - d); return x.toISOString().slice(0, 10); };
|
||||||
|
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||||
|
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||||
|
wrap.innerHTML = `<div class="card" style="width:420px;max-width:100%"><h2>${t('exportCsv')}</h2>
|
||||||
|
<p><button onclick="downloadStatsCsv(${Date.now() - 7*864e5}, ${Date.now()})">${t('expWeek')}</button>
|
||||||
|
<button onclick="downloadStatsCsv(${Date.now() - 30*864e5}, ${Date.now()})">${t('expMonth')}</button>
|
||||||
|
<button onclick="downloadStatsCsv(${Date.now() - 365*864e5}, ${Date.now()})">${t('expYear')}</button></p>
|
||||||
|
<label>${t('expRange')}</label>
|
||||||
|
<div class="row"><div><label>${t('expStart')}</label><input id="exp-from" type="date" value="${ago(7)}"></div>
|
||||||
|
<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>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
}
|
||||||
|
function downloadStatsCsv(from, to) {
|
||||||
|
const q = new URLSearchParams({ export: 'csv', from: String(Math.floor(from)), to: String(Math.floor(to)) });
|
||||||
|
if (statsKeyF) q.set('key', statsKeyF);
|
||||||
|
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);
|
||||||
|
}
|
||||||
async function paintStats() {
|
async function paintStats() {
|
||||||
try {
|
try {
|
||||||
const q = '/api/stats?limit=500' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
|
const q = '/api/stats?limit=500' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
|
||||||
@ -652,8 +686,8 @@ async function paintStats() {
|
|||||||
<div class="kpi"><div class="k-lab">${t('kpiLat')}</div><div class="k-val">${fmtMs(avg)}</div><div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div></div>`;
|
<div class="kpi"><div class="k-lab">${t('kpiLat')}</div><div class="k-val">${fmtMs(avg)}</div><div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div></div>`;
|
||||||
paintModelTable(st.by_model || []);
|
paintModelTable(st.by_model || []);
|
||||||
paintSrcTable(st.by_source || []);
|
paintSrcTable(st.by_source || []);
|
||||||
paintKeyTable(st.by_key || []);
|
paintKeyTable(st.by_key || [], st.key_names || {});
|
||||||
paintRecords(st.records || []);
|
paintRecords(st.records || [], st.key_names || {});
|
||||||
renderKeySelect((st.by_key || []).map(k => k.name));
|
renderKeySelect((st.by_key || []).map(k => k.name));
|
||||||
} catch (e) { console.error('[stats]', e); }
|
} catch (e) { console.error('[stats]', e); }
|
||||||
}
|
}
|
||||||
@ -682,16 +716,16 @@ function paintSrcTable(rows) {
|
|||||||
<td class="num">${fmtN(r.tokens)}</td>
|
<td class="num">${fmtN(r.tokens)}</td>
|
||||||
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`).join('') + '</table></div>';
|
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`).join('') + '</table></div>';
|
||||||
}
|
}
|
||||||
function paintKeyTable(rows) {
|
function paintKeyTable(rows, keyNames) {
|
||||||
const el = $('#tb-key'); if (!el) return;
|
const el = $('#tb-key'); if (!el) return;
|
||||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||||
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thKey')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
|
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thKey')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
|
||||||
<th class="num">${t('thTokens')}</th><th class="num">${t('thAvgLat')}</th></tr>` +
|
<th class="num">${t('thTokens')}</th><th class="num">${t('thAvgLat')}</th></tr>` +
|
||||||
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(r.name)}</button></td>
|
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(keyNames[r.name] ? keyNames[r.name] + ' · ' + r.name : r.name)}</button></td>
|
||||||
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
|
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
|
||||||
<td class="num">${fmtN(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`).join('') + '</table></div>';
|
<td class="num">${fmtN(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`).join('') + '</table></div>';
|
||||||
}
|
}
|
||||||
function paintRecords(records) {
|
function paintRecords(records, keyNames) {
|
||||||
const el = $('#tb-recs'); if (!el) return;
|
const el = $('#tb-recs'); if (!el) return;
|
||||||
if (!records.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
if (!records.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||||
el.innerHTML = `<table><tr><th>${t('thTime')}</th><th class="num">${t('thStatus')}</th><th>${t('thKey')}</th><th>${t('thType')}</th><th>${t('thModel')}</th><th>${t('thSrc')}</th>
|
el.innerHTML = `<table><tr><th>${t('thTime')}</th><th class="num">${t('thStatus')}</th><th>${t('thKey')}</th><th>${t('thType')}</th><th>${t('thModel')}</th><th>${t('thSrc')}</th>
|
||||||
@ -699,7 +733,7 @@ function paintRecords(records) {
|
|||||||
records.slice().reverse().map(r => `<tr>
|
records.slice().reverse().map(r => `<tr>
|
||||||
<td class="t-tag">${fmtTime(r.time)}</td>
|
<td class="t-tag">${fmtTime(r.time)}</td>
|
||||||
<td class="num">${r.ok ? `<span class="tag tag-green">${r.status || 200}</span>` : `<span class="tag tag-red" title="${esc(r.error || '')}">${r.status || 500}</span>`}</td>
|
<td class="num">${r.ok ? `<span class="tag tag-green">${r.status || 200}</span>` : `<span class="tag tag-red" title="${esc(r.error || '')}">${r.status || 500}</span>`}</td>
|
||||||
<td>${esc(r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
|
<td>${esc(keyNames[r.key] ? keyNames[r.key] + ' · ' + r.key : r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
|
||||||
<td class="num">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(r.completion_tokens)}</td><td class="num">${fmtMs(r.latency_ms)}</td></tr>`).join('') + '</table>';
|
<td class="num">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(r.completion_tokens)}</td><td class="num">${fmtMs(r.latency_ms)}</td></tr>`).join('') + '</table>';
|
||||||
}
|
}
|
||||||
function showModelConfig(srcName, model) {
|
function showModelConfig(srcName, model) {
|
||||||
@ -957,7 +991,7 @@ function removeChatImg(i) { chatImages.splice(i, 1); renderChatImgs(); }
|
|||||||
async function renderSources() {
|
async function renderSources() {
|
||||||
const j = await api('/api/sources');
|
const j = await api('/api/sources');
|
||||||
const rows = j.sources.map(s => `<tr><td><b>${esc(s.name)}</b></td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
|
const rows = j.sources.map(s => `<tr><td><b>${esc(s.name)}</b></td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
|
||||||
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}<span class="muted"> ·${m.priority||0}</span></span>`).join('')}</td>
|
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}</span>`).join('')}</td>
|
||||||
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'"')})">${t('srcEdit')}</button>
|
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'"')})">${t('srcEdit')}</button>
|
||||||
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
|
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
|
||||||
$('#tab-sources').innerHTML = `
|
$('#tab-sources').innerHTML = `
|
||||||
@ -1006,9 +1040,8 @@ function editSource(name) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
function modelRow(m, i) {
|
function modelRow(m, i) {
|
||||||
return `<div class="model-row">
|
return `<div class="model-row" data-priority="${m.priority || 0}">
|
||||||
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
|
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
|
||||||
<input data-mi="${i}" class="m-prio" type="number" placeholder="priority" value="${m.priority || 0}" style="width:90px">
|
|
||||||
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
|
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
|
||||||
<button class="ghost del small" onclick="this.closest('.model-row').remove()">×</button>
|
<button class="ghost del small" onclick="this.closest('.model-row').remove()">×</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@ -1020,7 +1053,7 @@ function addModelRow() {
|
|||||||
async function saveSource(btn) {
|
async function saveSource(btn) {
|
||||||
const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({
|
const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({
|
||||||
id: row.querySelector('.m-id').value.trim(),
|
id: row.querySelector('.m-id').value.trim(),
|
||||||
priority: parseInt(row.querySelector('.m-prio').value) || 0,
|
priority: parseInt(row.dataset.priority) || 0,
|
||||||
kind: row.querySelector('.m-kind').value,
|
kind: row.querySelector('.m-kind').value,
|
||||||
})).filter(m => m.id);
|
})).filter(m => m.id);
|
||||||
let meta = {};
|
let meta = {};
|
||||||
@ -1062,41 +1095,44 @@ function srcShort(name) { return (name || '?').slice(0, 2).toUpperCase(); }
|
|||||||
const sortState = { lanes: [], origin: null, drag: null };
|
const sortState = { lanes: [], origin: null, drag: null };
|
||||||
async function renderSort() {
|
async function renderSort() {
|
||||||
const j = await api('/api/sources');
|
const j = await api('/api/sources');
|
||||||
const map = new Map();
|
|
||||||
j.sources.forEach(s => (s.models || []).forEach(m => {
|
|
||||||
if (m.kind === 'image') return; // image models never share the chat chain
|
|
||||||
const p = m.priority || 0;
|
|
||||||
if (!map.has(p)) map.set(p, []);
|
|
||||||
map.get(p).push({ src: s.name, id: m.id });
|
|
||||||
}));
|
|
||||||
sortState.lanes = [...map.entries()].sort((a, b) => b[0] - a[0]).map(([prio, models]) => {
|
|
||||||
models.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
|
|
||||||
models.forEach(m => m.uid = nexUid());
|
|
||||||
return { prio, models };
|
|
||||||
});
|
|
||||||
// attach per-block quota meta from the AUTO rules (rules are ordered exactly
|
|
||||||
// like the chain; each block of a model instance gets its own rule)
|
|
||||||
let autoR = [];
|
let autoR = [];
|
||||||
try { autoR = (await api('/api/auto')).rules || []; } catch (e) {}
|
try { autoR = (await api('/api/auto')).rules || []; } catch (e) {}
|
||||||
const un = autoR.slice();
|
const byModel = new Map();
|
||||||
sortState.lanes.forEach(lane => lane.models.forEach(m => {
|
const byPair = new Map();
|
||||||
const ri = un.findIndex(r => r.model === m.id);
|
const sourceRows = new Map();
|
||||||
if (ri >= 0) {
|
j.sources.forEach(s => (s.models || []).forEach(m => {
|
||||||
const r = un.splice(ri, 1)[0];
|
if (m.kind === 'image') return;
|
||||||
m.meta = { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 };
|
const it = { src: s.name, id: m.id, prio: m.priority || 0 };
|
||||||
} else m.meta = null;
|
byPair.set(m.id + '|' + s.name, it);
|
||||||
|
if (!byModel.has(m.id)) byModel.set(m.id, it);
|
||||||
|
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
|
||||||
|
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
|
||||||
}));
|
}));
|
||||||
// orphan slots: rules left over after attaching one rule per source block
|
if (autoR.length) {
|
||||||
// (a model may legitimately appear twice in the chain -> its 2nd..nth rules
|
const tiers = [];
|
||||||
// become extra slots, preserving the multi-tier schedule)
|
autoR.forEach((r, i) => {
|
||||||
un.forEach(r => {
|
const src = normSrc(r.source);
|
||||||
sortState.lanes.push({ prio: 0, models: [{ src: '*', id: r.model, uid: nexUid(),
|
const pair = src ? byPair.get(r.model + '|' + src) : null;
|
||||||
meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 } }] });
|
if (src && !pair) return;
|
||||||
});
|
const ref = pair || byModel.get(r.model) || { src: '*', id: r.model };
|
||||||
// model picker for the "add slot" control
|
const ti = Math.max(1, parseInt(r.tier) || (i + 1)) - 1;
|
||||||
let addModels = [];
|
if (!tiers[ti]) tiers[ti] = [];
|
||||||
try { addModels = (await api('/api/status')).models || []; } catch (e) {}
|
tiers[ti].push({
|
||||||
allModels = addModels;
|
src: ref.src || '*', id: r.model, uid: nexUid(),
|
||||||
|
meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
sortState.lanes = tiers.filter(Boolean).map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||||||
|
} else {
|
||||||
|
sortState.lanes = [...sourceRows.entries()].sort((a, b) => b[0] - a[0]).map(([prio, models]) => {
|
||||||
|
models.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
|
||||||
|
models.forEach(m => { m.uid = nexUid(); m.meta = null; });
|
||||||
|
return { prio, models };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// model picker for the "add slot" control: one option per (source, model)
|
||||||
|
// pair so identical model ids on different sources stay distinguishable
|
||||||
|
await loadModelPairs();
|
||||||
sortState.origin = JSON.stringify(sortState.lanes);
|
sortState.origin = JSON.stringify(sortState.lanes);
|
||||||
$('#tab-sort').innerHTML = `
|
$('#tab-sort').innerHTML = `
|
||||||
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
||||||
@ -1112,17 +1148,17 @@ async function renderSort() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
paintSort();
|
paintSort();
|
||||||
}
|
}
|
||||||
function scrBlockHtml(it, isFirst, li, ji) {
|
function scrBlockHtml(it, isFirst, li, ji, extraClass) {
|
||||||
const c = srcColor(it.src);
|
const c = srcColor(it.src);
|
||||||
const s = srcShort(it.src);
|
const s = srcShort(it.src);
|
||||||
return `
|
return `
|
||||||
<div class="scr-block" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
|
<div class="scr-block ${escAttr(extraClass || '')}" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
|
||||||
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
|
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
|
||||||
onclick="scrCtx(event,'${li}','${ji}')"
|
onclick="scrCtx(event,'${li}','${ji}')"
|
||||||
oncontextmenu="scrCtx(event,'${li}','${ji}')">
|
oncontextmenu="scrCtx(event,'${li}','${ji}')">
|
||||||
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
|
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
|
||||||
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</span>
|
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</span>
|
||||||
<span class="scr-name">${esc(it.id)}</span>
|
<span class="scr-name">${esc(it.id)}<em class="scr-srcname">${esc(it.src === '*' ? t('kAnySrc') : it.src)}</em></span>
|
||||||
${it.meta ? `<span class="scr-tag">${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}</span>` : ''}
|
${it.meta ? `<span class="scr-tag">${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}</span>` : ''}
|
||||||
<span class="scr-x" title="${escAttr(t('kDelB2'))}" onclick="event.stopPropagation();scrDelSlot('${li}','${ji}')">×</span>
|
<span class="scr-x" title="${escAttr(t('kDelB2'))}" onclick="event.stopPropagation();scrDelSlot('${li}','${ji}')">×</span>
|
||||||
<span class="scr-grip"><i></i><i></i><i></i></span>
|
<span class="scr-grip"><i></i><i></i><i></i></span>
|
||||||
@ -1178,6 +1214,10 @@ function paintSortNow(affected) {
|
|||||||
});
|
});
|
||||||
cv.querySelectorAll('.scr-lane').forEach(l => prev.set('lane:' + l.dataset.lane, l.getBoundingClientRect()));
|
cv.querySelectorAll('.scr-lane').forEach(l => prev.set('lane:' + l.dataset.lane, l.getBoundingClientRect()));
|
||||||
cv.querySelectorAll('.scr-block').forEach(b => prev.set('blk:' + b.dataset.key, b.getBoundingClientRect()));
|
cv.querySelectorAll('.scr-block').forEach(b => prev.set('blk:' + b.dataset.key, b.getBoundingClientRect()));
|
||||||
|
const preGrowL = new Set();
|
||||||
|
sortState.lanes.forEach(lane => lane.models.forEach((m, i) => {
|
||||||
|
if (i > 0 && (prevFirst.has(m.uid) || !prev.has('blk:' + m.uid))) preGrowL.add(m.uid);
|
||||||
|
}));
|
||||||
const html = [];
|
const html = [];
|
||||||
sortState.lanes.forEach((lane, li) => {
|
sortState.lanes.forEach((lane, li) => {
|
||||||
const n = sortState.lanes.length - li;
|
const n = sortState.lanes.length - li;
|
||||||
@ -1185,7 +1225,7 @@ function paintSortNow(affected) {
|
|||||||
html.push(`<div class="scr-gap" data-gap="${li}"></div>`);
|
html.push(`<div class="scr-gap" data-gap="${li}"></div>`);
|
||||||
html.push(`<div class="${cls}" data-lane="${li}">
|
html.push(`<div class="${cls}" data-lane="${li}">
|
||||||
<div class="scr-tier">${Array.from({ length: n }, () => '<span></span>').join('')}</div>
|
<div class="scr-tier">${Array.from({ length: n }, () => '<span></span>').join('')}</div>
|
||||||
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}</div>
|
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i, preGrowL.has(m.uid) ? 'pre-grow-l' : '')).join('')}</div>
|
||||||
</div>`);
|
</div>`);
|
||||||
});
|
});
|
||||||
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
|
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
|
||||||
@ -1243,7 +1283,7 @@ function paintSortNow(affected) {
|
|||||||
const peer2 = cv.querySelector(`.scr-lane[data-lane="${li + 1}"] .scr-block`);
|
const peer2 = cv.querySelector(`.scr-lane[data-lane="${li + 1}"] .scr-block`);
|
||||||
if (peer2) peer2.classList.add('grow-t');
|
if (peer2) peer2.classList.add('grow-t');
|
||||||
}
|
}
|
||||||
if (!isFirst && prevFirst.has(key)) b.classList.add('grow-l');
|
if (!isFirst && (prevFirst.has(key) || !prev.has('blk:' + key))) { b.classList.remove('pre-grow-l'); void b.offsetWidth; b.classList.add('grow-l'); }
|
||||||
if (!isLast && prevLast.has(key)) b.classList.add('grow-r');
|
if (!isLast && prevLast.has(key)) b.classList.add('grow-r');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -1432,41 +1472,22 @@ function sortReset() {
|
|||||||
paintSort(all);
|
paintSort(all);
|
||||||
}
|
}
|
||||||
async function saveSort() {
|
async function saveSort() {
|
||||||
const snap = await api('/api/sources');
|
try {
|
||||||
const byName = {}; snap.sources.forEach(x => byName[x.name] = x);
|
await persistAuto();
|
||||||
const groups = {};
|
sortState.origin = JSON.stringify(sortState.lanes);
|
||||||
sortState.lanes.forEach((lane, li) => lane.models.forEach(it => {
|
toast(t('sortSaved'));
|
||||||
(groups[it.src] = groups[it.src] || []).push({ id: it.id, priority: (sortState.lanes.length - li) * 10 });
|
} catch (e) { toast(e.message); }
|
||||||
}));
|
|
||||||
let dirty = 0;
|
|
||||||
for (const name of Object.keys(groups)) {
|
|
||||||
const base = byName[name];
|
|
||||||
if (!base) continue;
|
|
||||||
const seen = new Set();
|
|
||||||
const ordered = groups[name].filter(it => { if (seen.has(it.id)) return false; seen.add(it.id); return true; })
|
|
||||||
.map(it => ({ id: it.id, priority: it.priority, kind: 'chat' }));
|
|
||||||
(base.models || []).forEach(x => {
|
|
||||||
if (seen.has(x.id)) return;
|
|
||||||
ordered.push({ id: x.id, priority: x.priority || 0, kind: x.kind === 'image' ? 'image' : 'chat' });
|
|
||||||
});
|
|
||||||
if (JSON.stringify(ordered) !== JSON.stringify(base.models)) {
|
|
||||||
base.models = ordered;
|
|
||||||
await api('/api/sources', { method: 'POST', body: JSON.stringify(base) });
|
|
||||||
dirty++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast(dirty ? t('sortSaved') : t('sortNoChange'));
|
|
||||||
sortState.origin = JSON.stringify(sortState.lanes);
|
|
||||||
try { await persistAuto(); } catch (e) { toast(e.message); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- AUTO quota editing on sort blocks ---------- */
|
/* ---------- AUTO quota editing on sort blocks ---------- */
|
||||||
async function persistAuto() {
|
async function persistAuto() {
|
||||||
const rules = [];
|
const rules = [];
|
||||||
sortState.lanes.forEach(lane => lane.models.forEach(it => {
|
sortState.lanes.forEach((lane, li) => lane.models.forEach(it => {
|
||||||
const m = it.meta;
|
const m = it.meta;
|
||||||
rules.push({
|
rules.push({
|
||||||
model: it.id,
|
model: it.id,
|
||||||
|
source: it.src === '*' ? undefined : it.src,
|
||||||
|
tier: li + 1,
|
||||||
token_quota: m && m.quota ? +m.quota : 0,
|
token_quota: m && m.quota ? +m.quota : 0,
|
||||||
period: m && m.period ? m.period : '',
|
period: m && m.period ? m.period : '',
|
||||||
hours: m && m.hours ? +m.hours : 0,
|
hours: m && m.hours ? +m.hours : 0,
|
||||||
@ -1479,8 +1500,10 @@ function scrAddModal() {
|
|||||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('sortAdd')}</h2>
|
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('sortAdd')}</h2>
|
||||||
<label>${t('kModelB')}</label>
|
<label>${t('kModelB')} <span class="muted">${t('kSrcHint')}</span></label>
|
||||||
<select id="a-model">${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}</select>
|
<select id="a-model">${allModels.map(m => typeof m === 'string'
|
||||||
|
? `<option value="${escAttr(m)}">${esc(m)}</option>`
|
||||||
|
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`).join('')}</select>
|
||||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||||
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t('kQuotaHintB'))}">
|
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t('kQuotaHintB'))}">
|
||||||
<label>${t('kPeriodB')}</label>
|
<label>${t('kPeriodB')}</label>
|
||||||
@ -1504,14 +1527,15 @@ function scrAddModal() {
|
|||||||
$('#a-model').focus();
|
$('#a-model').focus();
|
||||||
}
|
}
|
||||||
function scrAddFromForm() {
|
function scrAddFromForm() {
|
||||||
const model = $('#a-model').value.trim();
|
const raw = $('#a-model').value.trim();
|
||||||
if (!model) { toast(t('kName')); return; }
|
if (!raw) { toast(t('kName')); return; }
|
||||||
|
const [id, src] = raw.split('|');
|
||||||
let q = parseInt($('#a-quota').value);
|
let q = parseInt($('#a-quota').value);
|
||||||
if (isNaN(q) || q < 0) q = 0;
|
if (isNaN(q) || q < 0) q = 0;
|
||||||
const p = $('#a-period').value;
|
const p = $('#a-period').value;
|
||||||
let h = parseInt($('#a-hours').value);
|
let h = parseInt($('#a-hours').value);
|
||||||
if (isNaN(h) || h < 1) h = 1;
|
if (isNaN(h) || h < 1) h = 1;
|
||||||
sortState.lanes.push({ models: [{ src: '*', id: model, uid: nexUid(), meta: { quota: q, period: p, hours: (p || q) ? h : 0 } }] });
|
sortState.lanes.push({ models: [{ src: src || '*', id: id, uid: nexUid(), meta: { quota: q, period: p, hours: (p || q) ? h : 0 } }] });
|
||||||
const li = sortState.lanes.length - 1;
|
const li = sortState.lanes.length - 1;
|
||||||
const w = $('#modal-wrap'); if (w) w.remove();
|
const w = $('#modal-wrap'); if (w) w.remove();
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
@ -1544,7 +1568,7 @@ function sortScopeEdit(li, ji) {
|
|||||||
const cur = it.meta || { quota: 0, period: '', hours: 0 };
|
const cur = it.meta || { quota: 0, period: '', hours: 0 };
|
||||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}</h2>
|
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}${it.src !== '*' ? ' · ' + esc(it.src) : ''}</h2>
|
||||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||||
<input id="q-quota" type="number" min="0" step="1"
|
<input id="q-quota" type="number" min="0" step="1"
|
||||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.quota || ''}">
|
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.quota || ''}">
|
||||||
@ -1654,6 +1678,17 @@ async function delAdapter(name) {
|
|||||||
/* ---------- keys tab ---------- */
|
/* ---------- keys tab ---------- */
|
||||||
let allModels = [];
|
let allModels = [];
|
||||||
let scopeDragEl = null;
|
let scopeDragEl = null;
|
||||||
|
async function loadModelPairs() {
|
||||||
|
let srcs = [];
|
||||||
|
try { srcs = (await api('/api/sources')).sources || []; } catch (e) {}
|
||||||
|
const pairs = [];
|
||||||
|
(srcs || []).forEach(s => (s.models || []).forEach(m => {
|
||||||
|
if (m.kind === 'image') return;
|
||||||
|
pairs.push({ src: s.name, id: m.id, label: m.id + ' · ' + s.name, key: m.id + '|' + s.name });
|
||||||
|
}));
|
||||||
|
if (pairs.length) { allModels = pairs; return; }
|
||||||
|
try { allModels = (await api('/api/status')).models || []; } catch (e) { allModels = []; }
|
||||||
|
}
|
||||||
function maskKey(k) { return k.length > 12 ? k.slice(0, 6) + '…' + k.slice(-6) : k; }
|
function maskKey(k) { return k.length > 12 ? k.slice(0, 6) + '…' + k.slice(-6) : k; }
|
||||||
function fmtCreated(ts) { if (!ts) return '—'; const d = new Date(ts * 1000);
|
function fmtCreated(ts) { if (!ts) return '—'; const d = new Date(ts * 1000);
|
||||||
const p = x => String(x).padStart(2, '0');
|
const p = x => String(x).padStart(2, '0');
|
||||||
@ -1674,15 +1709,13 @@ async function renderKeysUser(me) {
|
|||||||
<td><span class="kr-key">${esc(me.key)}</span>
|
<td><span class="kr-key">${esc(me.key)}</span>
|
||||||
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t('kCopy')}</button></td>
|
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t('kCopy')}</button></td>
|
||||||
<td>${(me.models && me.models.length)
|
<td>${(me.models && me.models.length)
|
||||||
? me.models.map(m => `<span class="tag tag-blue" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>`).join('')
|
? me.models.map(m => { const src = normSrc(m.source); return `<span class="tag tag-blue" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${src ? ' · ' + esc(src) : ''}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>` }).join('')
|
||||||
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
|
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
|
||||||
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
|
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
async function renderKeysAdmin() {
|
async function renderKeysAdmin() {
|
||||||
let models = [];
|
await loadModelPairs();
|
||||||
try { models = (await api('/api/status')).models || []; } catch (e) {}
|
|
||||||
allModels = models;
|
|
||||||
$('#tab-keys').innerHTML = `
|
$('#tab-keys').innerHTML = `
|
||||||
<div class="key-actions">
|
<div class="key-actions">
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
@ -1712,6 +1745,7 @@ function keyCanvasHtml(k) {
|
|||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<span class="muted">${fmtCreated(k.created_at)}</span>
|
<span class="muted">${fmtCreated(k.created_at)}</span>
|
||||||
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
|
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
|
||||||
|
<button class="ghost small errc" title="${escAttr(t('kDel'))}" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="kc-blocks" data-key="${escAttr(k.key)}">
|
<div class="kc-blocks" data-key="${escAttr(k.key)}">
|
||||||
${scopes.length ? '' : `<span class="mb-empty">${t('kEmptyB')}</span>`}
|
${scopes.length ? '' : `<span class="mb-empty">${t('kEmptyB')}</span>`}
|
||||||
@ -1722,17 +1756,23 @@ function keyCanvasHtml(k) {
|
|||||||
}
|
}
|
||||||
function scopeHtml(key, m) {
|
function scopeHtml(key, m) {
|
||||||
const qt = fmtQuota(m.token_quota);
|
const qt = fmtQuota(m.token_quota);
|
||||||
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(m.model)}"
|
const comb = scopeComb(m);
|
||||||
|
const src = normSrc(m.source);
|
||||||
|
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(comb)}"
|
||||||
data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || '')}" data-hours="${m.hours || 0}"`;
|
data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || '')}" data-hours="${m.hours || 0}"`;
|
||||||
return `<span class="mb" draggable="true" ${attrs} title="${escAttr(t('kBrickH'))}"
|
return `<span class="mb" draggable="true" ${attrs} title="${escAttr(t('kBrickH'))}"
|
||||||
onclick="scopeEdit('${escAttr(key)}','${escAttr(m.model)}')"
|
onclick="scopeEdit('${escAttr(key)}','${escAttr(comb)}')"
|
||||||
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(m.model)}')">
|
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(comb)}')">
|
||||||
<span class="mb-ico">◆</span>
|
<span class="mb-ico">◆</span>
|
||||||
<span class="mb-name">${esc(m.model)}</span>
|
<span class="mb-name">${esc(m.model)}${src ? `<em class="mb-src">${esc(src)}</em>` : ''}</span>
|
||||||
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
|
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
|
||||||
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(m.model)}')">⧉</span>
|
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(comb)}')">⧉</span>
|
||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
|
function normSrc(s) { return (!s || s === 'undefined' || s === 'null') ? '' : s; }
|
||||||
|
function scopeComb(m) { const s = normSrc(m.source); return s ? m.model + '|' + s : m.model; }
|
||||||
|
function splitCombKey(comb) { const p = String(comb).split('|'); return { model: p[0], source: normSrc(p[1]) }; }
|
||||||
|
function scopeUncomb(comb) { const p = String(comb).split('|'); return { model: p[0], source: p[1] || '' }; }
|
||||||
function fmtQuota(q) { q = +q || 0; if (!q) return '∞';
|
function fmtQuota(q) { q = +q || 0; if (!q) return '∞';
|
||||||
if (q >= 1e9) return (q / 1e9).toFixed(1) + 'B';
|
if (q >= 1e9) return (q / 1e9).toFixed(1) + 'B';
|
||||||
if (q >= 1e6) return (q / 1e6).toFixed(1) + 'M';
|
if (q >= 1e6) return (q / 1e6).toFixed(1) + 'M';
|
||||||
@ -1753,12 +1793,17 @@ function quantBadge(quota, period, hours) {
|
|||||||
return fmtQuota(quota) + periodText(period, hours);
|
return fmtQuota(quota) + periodText(period, hours);
|
||||||
}
|
}
|
||||||
function readScopes(canvas) {
|
function readScopes(canvas) {
|
||||||
return [...canvas.querySelectorAll('.mb')].map(b => ({
|
return [...canvas.querySelectorAll('.mb')].map(b => {
|
||||||
model: b.dataset.model,
|
const comb = b.dataset.model.split('|');
|
||||||
token_quota: parseInt(b.dataset.quota) || 0,
|
const src = comb.length > 1 ? normSrc(comb[1]) : '';
|
||||||
period: b.dataset.period || '',
|
return {
|
||||||
hours: parseInt(b.dataset.hours) || 0,
|
model: comb[0],
|
||||||
}));
|
source: src || undefined,
|
||||||
|
token_quota: parseInt(b.dataset.quota) || 0,
|
||||||
|
period: b.dataset.period || '',
|
||||||
|
hours: parseInt(b.dataset.hours) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function putScope(key, scopes) {
|
async function putScope(key, scopes) {
|
||||||
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: scopes }) });
|
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: scopes }) });
|
||||||
@ -1772,48 +1817,51 @@ async function scopePush(key) {
|
|||||||
await loadKeys();
|
await loadKeys();
|
||||||
scopeEdit(key, 'AUTO');
|
scopeEdit(key, 'AUTO');
|
||||||
}
|
}
|
||||||
async function scopeDup(key, model) {
|
async function scopeDup(key, comb) {
|
||||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const scopes = readScopes(canvas);
|
const scopes = readScopes(canvas);
|
||||||
const src = scopes.find(s => s.model === model);
|
const src = scopes.find(s => scopeComb(s) === comb);
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
scopes.push({ model: src.model, token_quota: src.token_quota });
|
scopes.push({ model: src.model, source: src.source, token_quota: src.token_quota });
|
||||||
try { await putScope(key, scopes); toast(t('kDupOK')); } catch (e) { toast(e.message); }
|
try { await putScope(key, scopes); toast(t('kDupOK')); } catch (e) { toast(e.message); }
|
||||||
}
|
}
|
||||||
async function scopeRm(key, model) {
|
async function scopeRm(key, comb) {
|
||||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const scopes = readScopes(canvas).filter(s => s.model !== model);
|
const scopes = readScopes(canvas).filter(s => scopeComb(s) !== comb);
|
||||||
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
|
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
toast(t('toastDelOk'));
|
toast(t('toastDelOk'));
|
||||||
}
|
}
|
||||||
function scopeEdit(key, model) {
|
function scopeEdit(key, comb) {
|
||||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const cur = readScopes(canvas).find(s => s.model === model) || { model: '', token_quota: 0 };
|
const sc = readScopes(canvas).find(s => scopeComb(s) === comb)
|
||||||
|
|| { model: '', source: '', token_quota: 0, period: '', hours: 0 };
|
||||||
|
const curM = sc.model;
|
||||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||||
const opts = ['AUTO', ...allModels];
|
const opts = [{ key: 'AUTO', label: 'AUTO' }].concat(allModels.map(m => typeof m === 'string'
|
||||||
if (cur.model && !opts.includes(cur.model)) opts.unshift(cur.model);
|
? { key: m, label: m } : { key: m.key, label: m.label }));
|
||||||
|
if (curM && !opts.some(o => o.key === comb)) opts.unshift({ key: comb, label: comb });
|
||||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')}</h2>
|
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')}</h2>
|
||||||
<label>${t('kModelB')}</label>
|
<label>${t('kModelB')} <span class="muted">${t('kSrcHint')}</span></label>
|
||||||
<select id="sc-model">${opts.map(m => `<option value="${escAttr(m)}" ${m === cur.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}</select>
|
<select id="sc-model">${opts.map(o => `<option value="${escAttr(o.key)}" ${o.key === comb ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>
|
||||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||||
<input id="sc-quota" type="number" min="0" step="1"
|
<input id="sc-quota" type="number" min="0" step="1"
|
||||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.token_quota ? cur.token_quota : ''}">
|
placeholder="${escAttr(t('kQuotaHintB'))}" value="${sc.token_quota ? sc.token_quota : ''}">
|
||||||
<label>${t('kPeriodB')}</label>
|
<label>${t('kPeriodB')}</label>
|
||||||
<select id="sc-period">
|
<select id="sc-period">
|
||||||
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
|
<option value="" ${!sc.period ? 'selected' : ''}>${t('kPerNothing')}</option>
|
||||||
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
|
<option value="hour" ${sc.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
|
||||||
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
|
<option value="week" ${sc.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
|
||||||
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
|
<option value="month" ${sc.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
|
||||||
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
|
<option value="nhour" ${sc.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
|
||||||
</select>
|
</select>
|
||||||
<div id="sc-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
|
<div id="sc-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
|
||||||
<input id="sc-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
|
<input id="sc-hours" type="number" min="1" step="1" value="${sc.hours || 24}"></div>
|
||||||
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(cur.model)}', this)">${t('kSaveScope')}</button>
|
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(comb)}', this)">${t('kSaveScope')}</button>
|
||||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(wrap);
|
document.body.appendChild(wrap);
|
||||||
@ -1823,20 +1871,22 @@ function scopeEdit(key, model) {
|
|||||||
});
|
});
|
||||||
$('#sc-model').focus();
|
$('#sc-model').focus();
|
||||||
}
|
}
|
||||||
async function scopeSave(key, oldModel, btn) {
|
async function scopeSave(key, oldComb, btn) {
|
||||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const model = $('#sc-model').value.trim();
|
const comb = $('#sc-model').value.trim();
|
||||||
if (!model) { toast(t('kName')); return; }
|
if (!comb) { toast(t('kName')); return; }
|
||||||
|
const parts = splitCombKey(comb);
|
||||||
let q = parseInt($('#sc-quota').value);
|
let q = parseInt($('#sc-quota').value);
|
||||||
if (isNaN(q) || isNaN(q) || q < 0) q = 0;
|
if (isNaN(q) || q < 0) q = 0;
|
||||||
let hours = parseInt($('#sc-hours').value);
|
let hours = parseInt($('#sc-hours').value);
|
||||||
if (isNaN(hours) || hours < 1) hours = 1;
|
if (isNaN(hours) || hours < 1) hours = 1;
|
||||||
const period = $('#sc-period').value;
|
const period = $('#sc-period').value;
|
||||||
const scopes = readScopes(canvas);
|
const scopes = readScopes(canvas);
|
||||||
const i = scopes.findIndex(s => s.model === oldModel);
|
const entry = { model: parts.model, source: parts.source || undefined, token_quota: q, period, hours };
|
||||||
if (i < 0) scopes.push({ model, token_quota: q, period, hours });
|
const i = scopes.findIndex(s => scopeComb(s) === oldComb);
|
||||||
else scopes[i] = { model, token_quota: q, period, hours };
|
if (i < 0) scopes.push(entry);
|
||||||
|
else scopes[i] = entry;
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await putScope(key, scopes);
|
await putScope(key, scopes);
|
||||||
@ -1892,16 +1942,16 @@ function bindCanvasDrop(cv) {
|
|||||||
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
|
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function moveBrick(from, to, model) {
|
async function moveBrick(from, to, comb) {
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
const sf = document.querySelector(`.key-canvas[data-key="${CSS.escape(from)}"]`);
|
const sf = document.querySelector(`.key-canvas[data-key="${CSS.escape(from)}"]`);
|
||||||
const st = document.querySelector(`.key-canvas[data-key="${CSS.escape(to)}"]`);
|
const st = document.querySelector(`.key-canvas[data-key="${CSS.escape(to)}"]`);
|
||||||
if (!sf || !st) return;
|
if (!sf || !st) return;
|
||||||
let fs = readScopes(sf), ts = readScopes(st);
|
let fs = readScopes(sf), ts = readScopes(st);
|
||||||
const b = fs.find(s => s.model === model);
|
const b = fs.find(s => scopeComb(s) === comb);
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
fs = fs.filter(s => s.model !== model);
|
fs = fs.filter(s => scopeComb(s) !== comb);
|
||||||
const dup = ts.some(s => s.model === model);
|
const dup = ts.some(s => scopeComb(s) === comb);
|
||||||
if (!dup) ts.push(b);
|
if (!dup) ts.push(b);
|
||||||
try {
|
try {
|
||||||
await putScope(from, fs);
|
await putScope(from, fs);
|
||||||
|
|||||||
@ -57,9 +57,14 @@ type Provider struct {
|
|||||||
adapter string
|
adapter string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sem chan struct{}
|
sem chan struct{}
|
||||||
health health
|
health health
|
||||||
|
lastProbe struct {
|
||||||
|
ok bool
|
||||||
|
err string
|
||||||
|
at int64
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Source, vm *lua.VM) *Provider {
|
func New(cfg config.Source, vm *lua.VM) *Provider {
|
||||||
@ -175,6 +180,48 @@ func (p *Provider) Available() bool {
|
|||||||
return p.health.available()
|
return p.health.available()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probe performs a lightweight reachability + auth check against the source
|
||||||
|
// using its best chat model (1-token). It records the result for Status().
|
||||||
|
func (p *Provider) Probe(ctx context.Context) (bool, string) {
|
||||||
|
ok := false
|
||||||
|
msg := ""
|
||||||
|
model := p.bestChatModel()
|
||||||
|
if pm := p.ModelByID(model); pm != nil && pm.Kind == "image" {
|
||||||
|
model = ""
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
if ms := p.Models(); len(ms) > 0 {
|
||||||
|
model = ms[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model != "" {
|
||||||
|
_, err := p.Chat(ctx, &types.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||||
|
MaxTokens: 1,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
ok = true
|
||||||
|
} else {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg = "no chat model configured"
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.lastProbe.ok = ok
|
||||||
|
p.lastProbe.err = msg
|
||||||
|
p.lastProbe.at = time.Now().Unix()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return ok, msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) LastProbe() (bool, string, int64) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.lastProbe.ok, p.lastProbe.err, p.lastProbe.at
|
||||||
|
}
|
||||||
|
|
||||||
// ReportStatus records an upstream HTTP status for backoff decisions.
|
// ReportStatus records an upstream HTTP status for backoff decisions.
|
||||||
func (p *Provider) ReportStatus(code int) {
|
func (p *Provider) ReportStatus(code int) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@ -331,21 +378,23 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ch := make(chan types.UnifiedChunk, 64)
|
ch := make(chan types.UnifiedChunk, 64)
|
||||||
|
sel := <-rc
|
||||||
|
if sel.err != nil {
|
||||||
|
p.reportError()
|
||||||
|
p.Release()
|
||||||
|
return nil, sel.err
|
||||||
|
}
|
||||||
|
if sel.resp.StatusCode != 200 {
|
||||||
|
raw, _ := io.ReadAll(sel.resp.Body)
|
||||||
|
sel.resp.Body.Close()
|
||||||
|
p.ReportStatus(sel.resp.StatusCode)
|
||||||
|
p.Release()
|
||||||
|
return nil, fmt.Errorf("api error %d: %s", sel.resp.StatusCode, truncate(string(raw), 500))
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
defer p.Release()
|
defer p.Release()
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
sel := <-rc
|
|
||||||
if sel.err != nil {
|
|
||||||
p.reportError()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer sel.resp.Body.Close()
|
defer sel.resp.Body.Close()
|
||||||
if sel.resp.StatusCode != 200 {
|
|
||||||
raw, _ := io.ReadAll(sel.resp.Body)
|
|
||||||
p.ReportStatus(sel.resp.StatusCode)
|
|
||||||
_ = raw
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scanner := bufio.NewScanner(sel.resp.Body)
|
scanner := bufio.NewScanner(sel.resp.Body)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Registry holds all configured providers and routes model requests.
|
// Registry holds all configured providers and routes model requests.
|
||||||
@ -143,6 +145,36 @@ func (r *Registry) ProviderForModel(model string) *Provider {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProviderForSlot returns the provider for a (model, source) slot. When
|
||||||
|
// source is empty it behaves like ProviderForModel (owner of the model id);
|
||||||
|
// when source is set it returns only that exact source (nil if the source
|
||||||
|
// does not serve the model).
|
||||||
|
func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
||||||
|
model = strings.ToLower(strings.TrimSpace(model))
|
||||||
|
source = strings.TrimSpace(source)
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
if source == "" {
|
||||||
|
p, ok := r.byModel[model]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
for _, p := range r.providers {
|
||||||
|
if !strings.EqualFold(p.Name(), source) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range p.cfg.Models {
|
||||||
|
if strings.EqualFold(m.ID, model) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Default returns the highest-priority available provider.
|
// Default returns the highest-priority available provider.
|
||||||
func (r *Registry) Default() *Provider {
|
func (r *Registry) Default() *Provider {
|
||||||
chain := r.AUTOChain()
|
chain := r.AUTOChain()
|
||||||
@ -161,6 +193,27 @@ type SourceStatus struct {
|
|||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
MaxConcurrent int `json:"max_concurrent"`
|
MaxConcurrent int `json:"max_concurrent"`
|
||||||
|
LiveAvailable bool `json:"live_available"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
LastChecked int64 `json:"last_checked,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeAll runs a live reachability check for every provider (in parallel).
|
||||||
|
func (r *Registry) ProbeAll(ctx context.Context) {
|
||||||
|
r.mu.RLock()
|
||||||
|
providers := append([]*Provider(nil), r.providers...)
|
||||||
|
r.mu.RUnlock()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, p := range providers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p *Provider) {
|
||||||
|
defer wg.Done()
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
p.Probe(probeCtx)
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) Status() []SourceStatus {
|
func (r *Registry) Status() []SourceStatus {
|
||||||
@ -168,16 +221,20 @@ func (r *Registry) Status() []SourceStatus {
|
|||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
out := make([]SourceStatus, 0, len(r.providers))
|
out := make([]SourceStatus, 0, len(r.providers))
|
||||||
for _, p := range r.providers {
|
for _, p := range r.providers {
|
||||||
s := SourceStatus{
|
live, lastErr, lastAt := p.LastProbe()
|
||||||
Name: p.Name(),
|
s := SourceStatus{
|
||||||
Adapter: p.Adapter(),
|
Name: p.Name(),
|
||||||
BaseURL: p.Config().BaseURL,
|
Adapter: p.Adapter(),
|
||||||
Models: p.Models(),
|
BaseURL: p.Config().BaseURL,
|
||||||
Available: p.Available(),
|
Models: p.Models(),
|
||||||
Healthy: p.Available(),
|
Available: p.Available(),
|
||||||
MaxConcurrent: p.MaxConcurrent(),
|
Healthy: p.Available(),
|
||||||
}
|
MaxConcurrent: p.MaxConcurrent(),
|
||||||
out = append(out, s)
|
LiveAvailable: live,
|
||||||
|
LastError: lastErr,
|
||||||
|
LastChecked: lastAt,
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user