mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(auto): quota-aware AUTO slot scheduling (model tiers, hour/week/month resets) + key scope periods; fix key/slot create/delete UX
This commit is contained in:
@ -3,6 +3,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@ -122,16 +123,53 @@ func (c *Config) ApplyDefaults() error {
|
||||
type RuntimeConfig struct {
|
||||
Sources []Source `json:"sources"`
|
||||
Keys []GWKey `json:"keys,omitempty"`
|
||||
Auto []ModelScope `json:"auto,omitempty"`
|
||||
}
|
||||
|
||||
// GWKey is a gateway API key persisted in the runtime store. Role is "admin"
|
||||
// (full management) or "user" (sees only its own key); Models is the allowed
|
||||
// model whitelist (nil/empty = all models).
|
||||
// model scope with per-model token quota (0 = unlimited).
|
||||
type GWKey struct {
|
||||
Key string `json:"key"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Models []ModelScope `json:"models,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ModelScope is one allowed model for a key, or one AUTO scheduling slot,
|
||||
// with an optional token quota and reset period. TokenQuota 0 = unlimited;
|
||||
// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour"
|
||||
// uses Hours as the window length in hours.
|
||||
type ModelScope struct {
|
||||
Model string `json:"model"`
|
||||
TokenQuota int64 `json:"token_quota"`
|
||||
Period string `json:"period,omitempty"`
|
||||
Hours int64 `json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts both the legacy "model-id" string form and the
|
||||
// {"model":"...","token_quota":N} object form so old runtime files keep
|
||||
// loading.
|
||||
func (m *ModelScope) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err == nil {
|
||||
m.Model = s
|
||||
return nil
|
||||
}
|
||||
var o struct {
|
||||
Model string `json:"model"`
|
||||
TokenQuota int64 `json:"token_quota"`
|
||||
Period string `json:"period"`
|
||||
Hours int64 `json:"hours"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &o); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Model = o.Model
|
||||
m.TokenQuota = o.TokenQuota
|
||||
m.Period = o.Period
|
||||
m.Hours = o.Hours
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -138,3 +138,20 @@ func (s *Store) DeleteKey(key string) (bool, error) {
|
||||
s.data.Keys = kept
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// AutoRules returns the persisted AUTO scheduling slots.
|
||||
func (s *Store) AutoRules() []ModelScope {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]ModelScope, len(s.data.Auto))
|
||||
copy(out, s.data.Auto)
|
||||
return out
|
||||
}
|
||||
|
||||
// SaveAutoRules persists the AUTO scheduling slots.
|
||||
func (s *Store) SaveAutoRules(entries []ModelScope) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.Auto = entries
|
||||
return s.persistLocked()
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
@ -50,12 +51,48 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.seedKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.seedAuto(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.rebuildRegistry(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// seedAuto migrates the legacy per-source model priority into flat AUTO
|
||||
// scheduling slots (one slot per model, priority order) the first time no
|
||||
// explicit auto rules exist.
|
||||
func (c *Core) seedAuto() error {
|
||||
if len(c.store.AutoRules()) > 0 {
|
||||
return nil
|
||||
}
|
||||
type item struct {
|
||||
model string
|
||||
prio int
|
||||
}
|
||||
var flat []item
|
||||
for _, s := range c.mergedSources() {
|
||||
for _, m := range s.Models {
|
||||
if m.Kind == "image" {
|
||||
continue
|
||||
}
|
||||
flat = append(flat, item{m.ID, m.Priority})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(flat, func(i, j int) bool {
|
||||
if flat[i].prio != flat[j].prio {
|
||||
return flat[i].prio > flat[j].prio
|
||||
}
|
||||
return flat[i].model < flat[j].model
|
||||
})
|
||||
entries := make([]config.ModelScope, 0, len(flat))
|
||||
for _, it := range flat {
|
||||
entries = append(entries, config.ModelScope{Model: it.model})
|
||||
}
|
||||
return c.store.SaveAutoRules(entries)
|
||||
}
|
||||
|
||||
// seedKeys migrates the static config gateway_keys into the runtime store as
|
||||
// admin keys (once), so later UI-created keys can share the same store.
|
||||
func (c *Core) seedKeys() error {
|
||||
@ -114,7 +151,7 @@ func (c *Core) ListKeys() []config.GWKey { return c.store.ListKeys() }
|
||||
func (c *Core) FindKey(key string) (config.GWKey, bool) { return c.store.KeyByValue(key) }
|
||||
|
||||
// CreateKey builds a new random gateway key and persists it.
|
||||
func (c *Core) CreateKey(name, role string, models []string, note string) (config.GWKey, error) {
|
||||
func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||
key := make([]byte, 16)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return config.GWKey{}, err
|
||||
@ -137,7 +174,7 @@ func (c *Core) CreateKey(name, role string, models []string, note string) (confi
|
||||
}
|
||||
|
||||
// UpdateKey mutates a key's name/role/model scope and persists it.
|
||||
func (c *Core) UpdateKey(key, name, role string, models []string, note string) (config.GWKey, error) {
|
||||
func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||
rec, ok := c.store.KeyByValue(key)
|
||||
if !ok {
|
||||
return config.GWKey{}, fmt.Errorf("key not found")
|
||||
@ -159,6 +196,29 @@ func (c *Core) UpdateKey(key, name, role string, models []string, note string) (
|
||||
// DeleteKey removes a key record; returns false if it did not exist.
|
||||
func (c *Core) DeleteKey(key string) (bool, error) { return c.store.DeleteKey(key) }
|
||||
|
||||
// ---- AUTO scheduling slots (web UI canvas) ----
|
||||
|
||||
// AutoRules returns the AUTO scheduling slots in priority order (slot 0 =
|
||||
// highest priority).
|
||||
func (c *Core) AutoRules() []config.ModelScope { return c.store.AutoRules() }
|
||||
|
||||
// SaveAutoRules persists the AUTO scheduling slots.
|
||||
func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
||||
clean := make([]config.ModelScope, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Model == "" {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, e)
|
||||
}
|
||||
return c.store.SaveAutoRules(clean)
|
||||
}
|
||||
|
||||
// Registry resolves model -> owning provider.
|
||||
func (c *Core) ProviderForModel(model string) *provider.Provider {
|
||||
return c.registry.ProviderForModel(model)
|
||||
}
|
||||
|
||||
// Config exposes the underlying configuration (read-only usage).
|
||||
func (c *Core) Config() *config.Config { return c.cfg }
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
@ -106,11 +107,15 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid
|
||||
}
|
||||
|
||||
// filterCandsByModels keeps only providers exposing at least one model of the
|
||||
// whitelist (used for user keys with a restricted model scope).
|
||||
func filterCandsByModels(cands []*provider.Provider, allow []string) []*provider.Provider {
|
||||
// scope (used for user keys with a restricted model scope). An "AUTO" scope
|
||||
// entry means the key is allowed to use any model.
|
||||
func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider {
|
||||
allowed := make(map[string]bool, len(allow))
|
||||
for _, m := range allow {
|
||||
allowed[m] = true
|
||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
||||
return cands
|
||||
}
|
||||
allowed[m.Model] = true
|
||||
}
|
||||
out := make([]*provider.Provider, 0, len(cands))
|
||||
for _, p := range cands {
|
||||
@ -124,11 +129,20 @@ func filterCandsByModels(cands []*provider.Provider, allow []string) []*provider
|
||||
return out
|
||||
}
|
||||
|
||||
// intersectModels restricts a model list to the whitelist (preserving order).
|
||||
func intersectModels(models, allow []string) []string {
|
||||
// intersectModels restricts a model list to the scope (preserving order). An
|
||||
// "AUTO" scope entry grants every model.
|
||||
func intersectModels(models []string, allow []config.ModelScope) []string {
|
||||
allowed := make(map[string]bool, len(allow))
|
||||
any := false
|
||||
for _, m := range allow {
|
||||
allowed[m] = true
|
||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
||||
any = true
|
||||
break
|
||||
}
|
||||
allowed[m.Model] = true
|
||||
}
|
||||
if any {
|
||||
return models
|
||||
}
|
||||
out := make([]string, 0, len(models))
|
||||
seen := map[string]bool{}
|
||||
@ -141,6 +155,60 @@ func intersectModels(models, allow []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// checkModelScope validates the effective model against the key's model scope
|
||||
// and token quota. Returns an error message when rejected. A scope entry with
|
||||
// model "AUTO" grants all models; its quota caps the key's total tokens.
|
||||
func (g *Gateway) checkModelScope(ctx context.Context, model string) string {
|
||||
allow := g.allowedModels(ctx)
|
||||
if allow == nil {
|
||||
return ""
|
||||
}
|
||||
for _, sc := range allow {
|
||||
if sc.Model != "" && strings.EqualFold(sc.Model, "AUTO") {
|
||||
if sc.TokenQuota > 0 {
|
||||
used := g.scopeTokens(ctx, sc)
|
||||
if used >= sc.TokenQuota {
|
||||
return fmt.Sprintf("token quota exceeded (%d/%d)", used, sc.TokenQuota)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, sc := range allow {
|
||||
if sc.Model != model {
|
||||
continue
|
||||
}
|
||||
if sc.TokenQuota > 0 {
|
||||
used := g.scopeTokens(ctx, sc)
|
||||
if used >= sc.TokenQuota {
|
||||
return fmt.Sprintf("token quota exceeded for %q (%d/%d)", model, used, sc.TokenQuota)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("model %q is not allowed for this key", model)
|
||||
}
|
||||
|
||||
// scopeTokens returns the tokens a scope entry has consumed within its reset
|
||||
// window (total for AUTO / per model otherwise).
|
||||
func (g *Gateway) scopeTokens(ctx context.Context, sc config.ModelScope) int64 {
|
||||
k := keyID(reqKey(ctx))
|
||||
if sc.Model != "" && strings.EqualFold(sc.Model, "AUTO") {
|
||||
return g.stats.KeyTokens(k)
|
||||
}
|
||||
win := AutoPeriodSeconds(sc.Period, sc.Hours)
|
||||
return g.stats.WindowTokens(sc.Model, win)
|
||||
}
|
||||
|
||||
func hasScopeModel(list []config.ModelScope, s string) bool {
|
||||
for _, x := range list {
|
||||
if x.Model == s || (x.Model != "" && strings.EqualFold(x.Model, "AUTO")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Gateway) resolveByModel(model string) ([]*provider.Provider, string) {
|
||||
if isAuto(model) {
|
||||
return g.core.Registry().Resolve("AUTO"), ""
|
||||
@ -195,8 +263,44 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if model == "" {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(req.Model), "AUTO") {
|
||||
plans := g.autoPlans()
|
||||
if len(plans) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot available (quota exhausted or none configured)")
|
||||
return
|
||||
}
|
||||
if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
inner := &types.ChatRequest{
|
||||
Messages: req.Messages,
|
||||
Temperature: req.Temperature,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Stream: req.Stream,
|
||||
Tools: req.Tools,
|
||||
ToolChoice: req.ToolChoice,
|
||||
DisableThinking: req.DisableThinking,
|
||||
ExtraBody: req.ExtraBody,
|
||||
}
|
||||
rec := &Req{
|
||||
Key: keyID(reqKey(ctx)),
|
||||
Type: "chat",
|
||||
OK: false,
|
||||
}
|
||||
if req.Stream {
|
||||
rec.Type = "stream"
|
||||
g.streamChatAuto(w, ctx, plans, inner, rec)
|
||||
return
|
||||
}
|
||||
g.singleChatAuto(w, ctx, plans, inner, rec)
|
||||
return
|
||||
}
|
||||
if !isAuto(model) {
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !containsStr(allow, model) {
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
|
||||
return
|
||||
}
|
||||
@ -209,6 +313,10 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if effective == "" {
|
||||
effective = firstModel(cands[0])
|
||||
}
|
||||
if msg := g.checkModelScope(r.Context(), effective); msg != "" {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
@ -300,13 +408,20 @@ func firstSource(cands []*provider.Provider) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
// effectiveImageModel picks the image model id that will actually be used so
|
||||
// quota checks can target it (AUTO resolves to the first image candidate).
|
||||
func effectiveImageModel(model string, cands []*provider.Provider) string {
|
||||
if !isAuto(model) && model != "" {
|
||||
return model
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
for _, id := range cands[0].Models() {
|
||||
if m := cands[0].ModelByID(id); m != nil && m.Kind == "image" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
||||
@ -441,6 +556,193 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
}
|
||||
}
|
||||
|
||||
// autoPlan is one schedulable AUTO slot: a model id pinned to its provider
|
||||
// with an optional token quota window. Quota-exhausted slots are skipped.
|
||||
type autoPlan struct {
|
||||
p *provider.Provider
|
||||
model string
|
||||
quota int64
|
||||
win int64
|
||||
}
|
||||
|
||||
// autoPlans builds the schedulable AUTO slots from the persisted rules. A
|
||||
// slot is schedulable while its model is available and (when quota > 0) the
|
||||
// tokens used within its reset window are below the quota.
|
||||
func (g *Gateway) autoPlans() []autoPlan {
|
||||
rules := g.core.AutoRules()
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
plans := make([]autoPlan, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
p := g.core.ProviderForModel(e.Model)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if m := p.ModelByID(e.Model); m != nil && m.Kind == "image" {
|
||||
continue
|
||||
}
|
||||
win := AutoPeriodSeconds(e.Period, e.Hours)
|
||||
if e.TokenQuota > 0 && g.stats.WindowTokens(e.Model, win) >= e.TokenQuota {
|
||||
continue
|
||||
}
|
||||
plans = append(plans, autoPlan{p: p, model: e.Model, quota: e.TokenQuota, win: win})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
// singleChatAuto runs a non-streaming AUTO request slot by slot: each slot
|
||||
// pins its own model; a slot whose provider errors out is skipped. The first
|
||||
// slot to answer wins; when every slot fails, the recorded error is from the
|
||||
// last one.
|
||||
func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, plans []autoPlan, req *types.ChatRequest, rec *Req) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
var lastErr error
|
||||
for _, pl := range plans {
|
||||
if !pl.p.Available() {
|
||||
continue
|
||||
}
|
||||
r := *req
|
||||
r.Model = pl.model
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
||||
rec.Compl = int64(resp.TokenUsage.Completion)
|
||||
rec.Source = usedSrc
|
||||
rec.Model = usedModel
|
||||
g.writeRec(rec)
|
||||
msg := RespMessage{Role: "assistant", Content: resp.Content}
|
||||
if resp.ReasoningContent != "" {
|
||||
msg.ReasoningContent = resp.ReasoningContent
|
||||
}
|
||||
if len(resp.ToolCalls) > 0 {
|
||||
msg.ToolCalls = toolCallsWire(resp.ToolCalls)
|
||||
}
|
||||
out := ChatCompletion{
|
||||
ID: newID(),
|
||||
Object: "chat.completion",
|
||||
Created: time.Now().Unix(),
|
||||
Model: usedModel,
|
||||
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
|
||||
}
|
||||
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
|
||||
out.Usage = &resp.TokenUsage
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no provider available")
|
||||
}
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Err = lastErr.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", lastErr.Error())
|
||||
}
|
||||
|
||||
// streamChatAuto streams an AUTO request. It stays pinned to the first slot
|
||||
// whose stream begins; a slot that fails to connect is skipped.
|
||||
func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, plans []autoPlan, req *types.ChatRequest, rec *Req) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
defer func() {
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
g.writeRec(rec)
|
||||
}()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
id := newID()
|
||||
created := time.Now().Unix()
|
||||
send := func(obj interface{}) bool {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
|
||||
return false
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !send(ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: "auto",
|
||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
|
||||
}) {
|
||||
return
|
||||
}
|
||||
var lastErr error
|
||||
for _, pl := range plans {
|
||||
if !pl.p.Available() {
|
||||
continue
|
||||
}
|
||||
r := *req
|
||||
r.Model = pl.model
|
||||
chunks, usedSrc, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel
|
||||
rec.Source = usedSrc
|
||||
}
|
||||
for ck := range chunks {
|
||||
chunk := ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||
}
|
||||
delta := RespMessage{Role: "assistant", Content: ck.Content}
|
||||
if ck.ReasoningContent != "" {
|
||||
delta.ReasoningContent = ck.ReasoningContent
|
||||
}
|
||||
if len(ck.ToolCalls) > 0 {
|
||||
delta.ToolCalls = ck.ToolCalls
|
||||
}
|
||||
choice := ChunkChoice{Index: 0, Delta: delta}
|
||||
if ck.Done {
|
||||
stop := "stop"
|
||||
choice.FinishReason = &stop
|
||||
}
|
||||
chunk.Choices = []ChunkChoice{choice}
|
||||
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3
|
||||
if !send(chunk) {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no provider available")
|
||||
}
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Err = lastErr.Error()
|
||||
stop := "stop"
|
||||
send(ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: "auto",
|
||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
||||
})
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
||||
@ -460,7 +762,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
if !isAuto(model) {
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !containsStr(allow, model) {
|
||||
if allow := g.allowedModels(r.Context()); allow != nil && !hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
|
||||
return
|
||||
}
|
||||
@ -474,6 +776,10 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured")
|
||||
return
|
||||
}
|
||||
if msg := g.checkModelScope(r.Context(), effectiveImageModel(model, cands)); msg != "" {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
|
||||
return
|
||||
}
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, Source: firstSource(cands), OK: false}
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
|
||||
@ -35,7 +37,7 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Models []string `json:"models"`
|
||||
Models []config.ModelScope `json:"models"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@ -63,7 +65,7 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Models []string `json:"models"`
|
||||
Models []config.ModelScope `json:"models"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@ -115,9 +117,9 @@ func (g *Gateway) handleKeyMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec})
|
||||
}
|
||||
|
||||
// allowedModels returns the model whitelist for the request's key; nil means
|
||||
// allowedModels returns the model scope for the request's key; nil means
|
||||
// unrestricted (admin keys and user keys without an explicit scope).
|
||||
func (g *Gateway) allowedModels(ctx context.Context) []string {
|
||||
func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
||||
if reqRole(ctx) == "admin" {
|
||||
return nil
|
||||
}
|
||||
@ -127,3 +129,33 @@ func (g *Gateway) allowedModels(ctx context.Context) []string {
|
||||
}
|
||||
return rec.Models
|
||||
}
|
||||
|
||||
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
|
||||
// current rules; PUT /api/auto replaces them (admin only).
|
||||
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"rules": g.core.AutoRules()})
|
||||
return
|
||||
}
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var body struct {
|
||||
Rules []config.ModelScope `json:"rules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveAutoRules(body.Rules); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "rules": g.core.AutoRules()})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
@ -72,6 +72,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleStatsAPI(w, r)
|
||||
case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"):
|
||||
g.handleKeysAPI(w, r)
|
||||
case r.URL.Path == "/api/auto":
|
||||
g.handleAutoAPI(w, r)
|
||||
case r.URL.Path == "/login":
|
||||
g.handleLogin(w, r)
|
||||
case r.URL.Path == "/api/login":
|
||||
@ -92,6 +94,7 @@ func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Req is one recorded gateway request (audit trail + per-key/per-model stats).
|
||||
@ -54,8 +55,11 @@ type Stats struct {
|
||||
byKeySrc map[string]map[string]*Stat
|
||||
recs []Req
|
||||
maxRecs int
|
||||
modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens
|
||||
}
|
||||
|
||||
const hourSec = 3600
|
||||
|
||||
func NewStats(maxRecords int) *Stats {
|
||||
if maxRecords <= 0 {
|
||||
maxRecords = 3000
|
||||
@ -66,6 +70,7 @@ func NewStats(maxRecords int) *Stats {
|
||||
bySrc: map[string]*Stat{},
|
||||
byKeyModel: map[string]map[string]*Stat{},
|
||||
byKeySrc: map[string]map[string]*Stat{},
|
||||
modelHour: map[string]map[int64]int64{},
|
||||
maxRecs: maxRecords,
|
||||
}
|
||||
}
|
||||
@ -123,12 +128,99 @@ func (s *Stats) Record(r Req) {
|
||||
s.byKeySrc[r.Key] = ks
|
||||
}
|
||||
inc(ks, r.Source, r)
|
||||
// window bucket for quota enforcement (per model, per unix hour)
|
||||
tok := r.Prompt + r.Compl
|
||||
if tok > 0 && r.Model != "" {
|
||||
h := r.Time / hourSec
|
||||
hm := s.modelHour[r.Model]
|
||||
if hm == nil {
|
||||
hm = map[int64]int64{}
|
||||
s.modelHour[r.Model] = hm
|
||||
}
|
||||
hm[h] += tok
|
||||
if len(hm) > 24*40 {
|
||||
for k := range hm {
|
||||
if k < h-24*40 {
|
||||
delete(hm, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.recs = append(s.recs, r)
|
||||
if len(s.recs) > s.maxRecs {
|
||||
s.recs = s.recs[len(s.recs)-s.maxRecs:]
|
||||
}
|
||||
}
|
||||
|
||||
// ModelTokens returns the tokens consumed per model for one gateway key id
|
||||
// (used for per-model token quota enforcement).
|
||||
func (s *Stats) ModelTokens(key string) map[string]int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string]int64{}
|
||||
for k, v := range s.byKeyModel[key] {
|
||||
out[k] = v.Tokens
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KeyTokens returns the total tokens consumed by one gateway key id.
|
||||
func (s *Stats) KeyTokens(key string) int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
a := s.byKey[key]
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
return a.Tokens
|
||||
}
|
||||
|
||||
// AutoPeriodSeconds maps a quota reset period to its window length in
|
||||
// seconds. "" → 0 (never resets); "hour" → 1h; "week" → 7d; "month" → 30d;
|
||||
// "nhour" → Hours (>=1) hours.
|
||||
func AutoPeriodSeconds(period string, hours int64) int64 {
|
||||
switch period {
|
||||
case "hour":
|
||||
return hourSec
|
||||
case "week":
|
||||
return 7 * 24 * hourSec
|
||||
case "month":
|
||||
return 30 * 24 * hourSec
|
||||
case "nhour":
|
||||
if hours < 1 {
|
||||
hours = 1
|
||||
}
|
||||
return hours * hourSec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// WindowTokens returns the tokens billed for the model within the last `sec`
|
||||
// seconds (0 = since forever).
|
||||
func (s *Stats) WindowTokens(model string, sec int64) int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now().Unix()
|
||||
hm := s.modelHour[model]
|
||||
if len(hm) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total int64
|
||||
if sec <= 0 {
|
||||
for _, v := range hm {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
cut := now - sec
|
||||
for h, v := range hm {
|
||||
if h*hourSec >= cut {
|
||||
total += v
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func rows(m map[string]*Stat) []StatsRow {
|
||||
out := make([]StatsRow, 0, len(m))
|
||||
for k, v := range m {
|
||||
|
||||
@ -147,6 +147,37 @@ td.t-tag { white-space:nowrap; }
|
||||
.keys-create label { font-size:11.5px; color:var(--muted); margin-bottom:4px; }
|
||||
.keys-create input, .keys-create select { margin:0; }
|
||||
@media (max-width:760px) { .keys-create { grid-template-columns:1fr; } }
|
||||
|
||||
/* key canvases — scratch-style model bricks per key */
|
||||
.key-actions { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
|
||||
.key-canvas { border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px; background:var(--card); }
|
||||
.kc-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.kc-blocks { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:12px;
|
||||
background:var(--card2); border:1px dashed var(--line); border-radius:12px; padding:14px; min-height:64px; }
|
||||
.kc-blocks.ovh { outline:2px dashed var(--accent); outline-offset:2px; }
|
||||
.mb { display:inline-flex; align-items:center; gap:8px; padding:9px 13px; border-radius:12px;
|
||||
background:linear-gradient(135deg,#41a3ff,#3f6ef5); color:#fff; font-size:13px; font-weight:700;
|
||||
cursor:grab; user-select:none; box-shadow:0 3px 10px rgba(63,110,245,.30); }
|
||||
.mb:active { cursor:grabbing; }
|
||||
.mb .mb-ico { font-size:11px; opacity:.85; }
|
||||
.mb .mb-name { font-family:ui-monospace,Menlo,Consolas,monospace; white-space:nowrap; }
|
||||
.mb .mb-quota { font-size:11px; font-weight:600; background:rgba(255,255,255,.18); padding:2px 8px;
|
||||
border-radius:99px; white-space:nowrap; }
|
||||
.mb .copy-b { cursor:pointer; opacity:.8; font-size:12px; padding:0 3px; }
|
||||
.mb .copy-b:hover { opacity:1; }
|
||||
.mb-ghost { position:fixed; pointer-events:none; opacity:.85; z-index:60; transform:rotate(2deg); }
|
||||
.mb-empty { color:var(--muted); font-size:12.5px; }
|
||||
.add-brick { display:inline-flex; align-items:center; gap:6px; padding:9px 16px; border-radius:12px;
|
||||
border:1.5px dashed var(--accent); background:transparent; color:var(--accent); font-weight:700;
|
||||
font-size:12.5px; cursor:pointer; font-family:inherit; }
|
||||
.add-brick:hover { background:rgba(63,110,245,.08); }
|
||||
#ctx-wrap { position:fixed; z-index:70; background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
box-shadow:0 10px 30px rgba(15,22,44,.18); padding:5px; min-width:130px; }
|
||||
.ctx-itm { display:flex; align-items:center; gap:8px; padding:8px 12px; border-radius:7px; cursor:pointer;
|
||||
font-size:13px; font-weight:600; }
|
||||
.ctx-itm:hover { background:var(--card2); }
|
||||
.ctx-itm.danger { color:var(--err); }
|
||||
.ctx-itm .ic { width:15px; text-align:center; }
|
||||
.filter-line { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
|
||||
.filter-line select { width:auto; margin:0; padding:6px 10px; }
|
||||
|
||||
@ -260,6 +291,9 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
background:rgba(0,0,0,.2); }
|
||||
.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; }
|
||||
.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;
|
||||
border:1px solid rgba(255,220,130,.35); cursor:pointer; }
|
||||
.scr-block .scr-grip { flex:0 0 auto; display:flex; flex-direction:column; gap:2px; padding:6px 4px;
|
||||
margin-left:2px; border-radius:6px; cursor:grab; background:rgba(255,255,255,.18);
|
||||
box-shadow:inset 0 1px 2px rgba(0,0,0,.18); transition:background .12s; touch-action:none; }
|
||||
@ -383,6 +417,12 @@ const STR = {
|
||||
kMeTitle:'我的密钥', kMeRole:'角色', kMeModels:'我可用的模型', kMeHint:'密钥不可在此新建或删除;需要变更请联系管理员。',
|
||||
kEmpty:'暂无其他密钥', kDelSelf:'不能删除当前登录所用密钥', kDelConfirm:'确定删除密钥 %s 吗?此后该密钥立即失效。',
|
||||
kAll:'不限',
|
||||
kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动',
|
||||
kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除',
|
||||
kFormTitle:'模型与 Token 配额', kModelB:'模型', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限',
|
||||
kPeriodB:'重置周期', kPerNothing:'不限(永不过期)', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数',
|
||||
kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除(从链中移除)',
|
||||
kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'(暂无模型 —— 点击 + 添加)', kAddB:'添加模型',
|
||||
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级自动选择可用源;点击任一模型可生成固定到该模型的配置。',
|
||||
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
|
||||
srcTitle:'源状态', srcCount:'共 %d 个',
|
||||
@ -409,7 +449,7 @@ const STR = {
|
||||
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
|
||||
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO(自动选择可用源)',
|
||||
sortTitle:'画布排序:拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型(kind=image)不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||||
sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载', sortNoChange:'无变更',
|
||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)',
|
||||
@ -427,9 +467,15 @@ const STR = {
|
||||
kNewKey:'New key (copy & save it now)', kNewOK:'Key created', kScope:'Model scope', kScopeTitle:'Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users',
|
||||
kScopeHint:'drag to order · add from dropdown · × to remove · copy/paste list', kScopeSel:'Pick a model…', kCopyList:'Copy list', kPasteList:'Paste list', kSaveScope:'Save',
|
||||
kScopeSaved:'Model scope saved', kScopeEmpty:'(all models, unlimited)', kScopeAll:'(empty = unlimited)',
|
||||
kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys cannot be created or deleted here; contact an admin to change. ',
|
||||
kEmpty:'No other keys', kDelSelf:'cannot delete the key you are logged in with', kDelConfirm:'Delete key %s? It will stop working immediately.',
|
||||
kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys cannot be created or deleted here; contact an admin to change.',
|
||||
kEmpty:'No other keys', kDelSelf:'cannot delete the key you are logged in with', kDelConfirm:'Delete key "%s"? It will stop working immediately.',
|
||||
kAll:'All',
|
||||
kBrickH:'Click to edit model & quota · right-click for more · drag to move to another key',
|
||||
kCopyB:'Copy', kEditB:'Edit', kDelB:'Delete',
|
||||
kFormTitle:'Model & token quota', kModelB:'Model', 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',
|
||||
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',
|
||||
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
|
||||
copyCfg:'Copy config', copyEnv:'Copy as env vars',
|
||||
srcTitle:'Sources', srcCount:'%d total',
|
||||
@ -456,7 +502,7 @@ const STR = {
|
||||
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',
|
||||
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', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes',
|
||||
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)',
|
||||
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)',
|
||||
@ -1006,6 +1052,8 @@ const SORT_COLORS = [
|
||||
['#a466e8', '#8b4dd4'], ['#e86f9c', '#d74f86'], ['#45c4c0', '#2aa8a4'],
|
||||
['#e8564a', '#d03f34'], ['#8ba3c7', '#6f8ab3'],
|
||||
];
|
||||
let sortUid = 0;
|
||||
function nexUid() { return 's' + (++sortUid); }
|
||||
function srcColor(name) {
|
||||
let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return SORT_COLORS[h % SORT_COLORS.length];
|
||||
@ -1023,11 +1071,36 @@ async function renderSort() {
|
||||
}));
|
||||
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 = [];
|
||||
try { autoR = (await api('/api/auto')).rules || []; } catch (e) {}
|
||||
const un = autoR.slice();
|
||||
sortState.lanes.forEach(lane => lane.models.forEach(m => {
|
||||
const ri = un.findIndex(r => r.model === m.id);
|
||||
if (ri >= 0) {
|
||||
const r = un.splice(ri, 1)[0];
|
||||
m.meta = { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 };
|
||||
} else m.meta = null;
|
||||
}));
|
||||
// orphan slots: rules left over after attaching one rule per source block
|
||||
// (a model may legitimately appear twice in the chain -> its 2nd..nth rules
|
||||
// become extra slots, preserving the multi-tier schedule)
|
||||
un.forEach(r => {
|
||||
sortState.lanes.push({ prio: 0, models: [{ src: '*', id: r.model, uid: nexUid(),
|
||||
meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 } }] });
|
||||
});
|
||||
// model picker for the "add slot" control
|
||||
let addModels = [];
|
||||
try { addModels = (await api('/api/status')).models || []; } catch (e) {}
|
||||
allModels = addModels;
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
$('#tab-sort').innerHTML = `
|
||||
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="scrAddModal()">+ ${t('sortAdd')}</button>
|
||||
<button class="ghost small" onclick="sortReset()">${t('sortReset')}</button>
|
||||
<button class="small" onclick="saveSort()">${t('sortSave')}</button></h2>
|
||||
<div class="sort-canvas" id="scr-canvas"></div>
|
||||
@ -1039,24 +1112,30 @@ async function renderSort() {
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
function scrBlockHtml(it, isFirst) {
|
||||
function scrBlockHtml(it, isFirst, li, ji) {
|
||||
const c = srcColor(it.src);
|
||||
const s = srcShort(it.src);
|
||||
return `
|
||||
<div class="scr-block" data-key="${escAttr(it.src + '|' + it.id)}"
|
||||
style="background:linear-gradient(135deg,${c[0]},${c[1]})">
|
||||
<div class="scr-block" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
|
||||
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
|
||||
onclick="scrCtx(event,'${li}','${ji}')"
|
||||
oncontextmenu="scrCtx(event,'${li}','${ji}')">
|
||||
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
|
||||
<span class="scr-ico">${esc(srcShort(it.src))}</span>
|
||||
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</span>
|
||||
<span class="scr-name">${esc(it.id)}</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-grip"><i></i><i></i><i></i></span>
|
||||
</div>`;
|
||||
}
|
||||
function findSlots(li, ji) { return sortState.lanes[li].models[ji]; }
|
||||
function paintSort(affected) {
|
||||
const cv = $('#scr-canvas');
|
||||
if (!cv) return;
|
||||
// compute the next lane-leaders so we can retract the outgoing ones first
|
||||
const nextFirst = new Set();
|
||||
sortState.lanes.forEach(lane => {
|
||||
if (lane.models.length) nextFirst.add(lane.models[0].src + '|' + lane.models[0].id);
|
||||
if (lane.models.length) nextFirst.add(lane.models[0].uid);
|
||||
});
|
||||
// outgoing leaders: currently first in their row but no longer first post-move
|
||||
const outgoing = [];
|
||||
@ -1106,7 +1185,7 @@ function paintSortNow(affected) {
|
||||
html.push(`<div class="scr-gap" data-gap="${li}"></div>`);
|
||||
html.push(`<div class="${cls}" data-lane="${li}">
|
||||
<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)).join('')}</div>
|
||||
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}</div>
|
||||
</div>`);
|
||||
});
|
||||
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
|
||||
@ -1285,13 +1364,13 @@ function onSortUp(e) {
|
||||
let minLi = item.li, maxLi = item.li;
|
||||
if (d.gap !== undefined) {
|
||||
// move to a new lane positioned at that gap
|
||||
sortState.lanes[item.li].models.splice(item.idx, 1);
|
||||
const m = sortState.lanes[item.li].models.splice(item.idx, 1)[0];
|
||||
let dst = d.gap;
|
||||
if (!sortState.lanes[item.li].models.length) {
|
||||
sortState.lanes.splice(item.li, 1);
|
||||
if (item.li < dst) dst -= 1;
|
||||
}
|
||||
sortState.lanes.splice(dst, 0, { models: [{ src: item.src, id: item.id }] });
|
||||
sortState.lanes.splice(dst, 0, { models: [m] });
|
||||
minLi = Math.min(item.li, dst);
|
||||
maxLi = Math.max(item.li, dst);
|
||||
} else if (typeof d.ins === 'number') {
|
||||
@ -1316,10 +1395,10 @@ function onSortUp(e) {
|
||||
sortState.drag = null;
|
||||
paintSort(affected);
|
||||
}
|
||||
function findLaneItem(key) {
|
||||
function findLaneItem(uid) {
|
||||
for (let i = 0; i < sortState.lanes.length; i++) {
|
||||
const idx = sortState.lanes[i].models.findIndex(m => m.src + '|' + m.id === key);
|
||||
if (idx >= 0) return { li: i, idx, src: sortState.lanes[i].models[idx].src, id: sortState.lanes[i].models[idx].id };
|
||||
const idx = sortState.lanes[i].models.findIndex(m => m.uid === uid);
|
||||
if (idx >= 0) return { li: i, idx, it: sortState.lanes[i].models[idx] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -1378,6 +1457,141 @@ async function saveSort() {
|
||||
}
|
||||
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 ---------- */
|
||||
async function persistAuto() {
|
||||
const rules = [];
|
||||
sortState.lanes.forEach(lane => lane.models.forEach(it => {
|
||||
const m = it.meta;
|
||||
rules.push({
|
||||
model: it.id,
|
||||
token_quota: m && m.quota ? +m.quota : 0,
|
||||
period: m && m.period ? m.period : '',
|
||||
hours: m && m.hours ? +m.hours : 0,
|
||||
});
|
||||
}));
|
||||
await api('/api/auto', { method: 'PUT', body: JSON.stringify({ rules }) });
|
||||
}
|
||||
function scrAddModal() {
|
||||
const cur = { quota: 0, period: '', hours: 0 };
|
||||
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:400px;max-width:100%"><h2>${t('sortAdd')}</h2>
|
||||
<label>${t('kModelB')}</label>
|
||||
<select id="a-model">${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}</select>
|
||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t('kQuotaHintB'))}">
|
||||
<label>${t('kPeriodB')}</label>
|
||||
<select id="a-period">
|
||||
<option value="" selected>${t('kPerNothing')}</option>
|
||||
<option value="hour">${t('kPerHour')}</option>
|
||||
<option value="week">${t('kPerWeek')}</option>
|
||||
<option value="month">${t('kPerMonth')}</option>
|
||||
<option value="nhour">${t('kPerHours')}</option>
|
||||
</select>
|
||||
<div id="a-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
|
||||
<input id="a-hours" type="number" min="1" step="1" value="24"></div>
|
||||
<p><button onclick="scrAddFromForm()">${t('kSaveScope')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
$('#a-period').addEventListener('change', () => {
|
||||
$('#a-hours').closest('#a-hours-box').style.display =
|
||||
$('#a-period').value === 'nhour' ? 'block' : 'none';
|
||||
});
|
||||
$('#a-model').focus();
|
||||
}
|
||||
function scrAddFromForm() {
|
||||
const model = $('#a-model').value.trim();
|
||||
if (!model) { toast(t('kName')); return; }
|
||||
let q = parseInt($('#a-quota').value);
|
||||
if (isNaN(q) || q < 0) q = 0;
|
||||
const p = $('#a-period').value;
|
||||
let h = parseInt($('#a-hours').value);
|
||||
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 } }] });
|
||||
const li = sortState.lanes.length - 1;
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
paintSort([li]);
|
||||
persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message));
|
||||
}
|
||||
function sortScopeSave(li, ji) {
|
||||
const it = sortState.lanes[li].models[ji];
|
||||
if (!it) return;
|
||||
let q = parseInt($('#q-quota').value);
|
||||
if (isNaN(q) || q < 0) q = 0;
|
||||
const p = $('#q-period').value;
|
||||
let h = parseInt($('#q-hours').value);
|
||||
if (isNaN(h) || h < 1) h = 1;
|
||||
it.meta = { quota: q, period: p, hours: (p || q) ? h : 0 };
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
paintSort([li]);
|
||||
persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message));
|
||||
}
|
||||
function scrCtx(e, li, ji) {
|
||||
if (e.button === 2) {
|
||||
sortBlockCtx(e, li, ji);
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
sortScopeEdit(li, ji);
|
||||
}
|
||||
function sortScopeEdit(li, ji) {
|
||||
const it = sortState.lanes[li].models[ji];
|
||||
if (!it) return;
|
||||
const cur = it.meta || { quota: 0, period: '', hours: 0 };
|
||||
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:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}</h2>
|
||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||
<input id="q-quota" type="number" min="0" step="1"
|
||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.quota || ''}">
|
||||
<label>${t('kPeriodB')}</label>
|
||||
<select id="q-period">
|
||||
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
|
||||
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
|
||||
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
|
||||
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
|
||||
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
|
||||
</select>
|
||||
<div id="q-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
|
||||
<input id="q-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
|
||||
<p><button onclick="sortScopeSave(${li},${ji},this)">${t('kSaveScope')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
$('#q-period').addEventListener('change', () => {
|
||||
$('#q-hours').closest('#q-hours-box').style.display =
|
||||
$('#q-period').value === 'nhour' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
function sortBlockCtx(e, li, ji) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const it = sortState.lanes[li].models[ji];
|
||||
if (!it) return;
|
||||
const items = [
|
||||
{ label: t('kEditQ'), icon: '✎', fn: () => sortScopeEdit(li, ji) },
|
||||
{ label: t('kDupB'), icon: '⧉', fn: () => {
|
||||
sortState.lanes[li].models.splice(ji + 1, 0, { src: it.src, id: it.id, uid: nexUid(), meta: it.meta ? { ...it.meta } : null });
|
||||
paintSort([li]);
|
||||
persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message));
|
||||
} },
|
||||
];
|
||||
if (it.meta) items.push({
|
||||
label: t('kClearQ'), icon: '♾', fn: () => {
|
||||
it.meta = null; paintSort([li]);
|
||||
persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message));
|
||||
} });
|
||||
items.push({ label: t('kDelB2'), icon: '✕', danger: true, fn: () => scrDelSlot(li, ji) });
|
||||
showCtx(e.clientX, e.clientY, items);
|
||||
}
|
||||
function scrDelSlot(li, ji) {
|
||||
sortState.lanes[li].models.splice(ji, 1);
|
||||
if (!sortState.lanes[li].models.length && sortState.lanes.length > 1) sortState.lanes.splice(li, 1);
|
||||
paintSort([li]);
|
||||
persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message));
|
||||
}
|
||||
|
||||
/* ---------- adapters tab ---------- */
|
||||
@ -1460,7 +1674,7 @@ async function renderKeysUser(me) {
|
||||
<td><span class="kr-key">${esc(me.key)}</span>
|
||||
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t('kCopy')}</button></td>
|
||||
<td>${(me.models && me.models.length)
|
||||
? me.models.map(m => `<span class="tag tag-blue">${esc(m)}</span>`).join('')
|
||||
? 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('')
|
||||
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
|
||||
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
|
||||
</div>`;
|
||||
@ -1470,140 +1684,270 @@ async function renderKeysAdmin() {
|
||||
try { models = (await api('/api/status')).models || []; } catch (e) {}
|
||||
allModels = models;
|
||||
$('#tab-keys').innerHTML = `
|
||||
<div class="card"><h2>${t('kCreate')}</h2>
|
||||
<div class="keys-create">
|
||||
<label>${t('kName')}</label><input id="kc-name" placeholder="alice">
|
||||
<label>${t('kRole')}</label>
|
||||
<select id="kc-role"><option value="user">${t('kRoleUser')}</option><option value="admin">${t('kRoleAdmin')}</option></select>
|
||||
<label>${t('kNote')}</label><input id="kc-note" placeholder="…">
|
||||
<button onclick="createKey()">${t('kCreateBtn')}</button>
|
||||
<div class="key-actions">
|
||||
<span class="grow"></span>
|
||||
<button class="small" onclick="openKeyModal()">${t('kCreate')}</button>
|
||||
</div>
|
||||
<div id="k-newbox"></div>
|
||||
</div>
|
||||
<div class="card"><h2>${t('keysTitle')}</h2><div class="muted">${t('keysHint')}</div><div id="k-list"></div></div>`;
|
||||
<div id="k-list"></div>`;
|
||||
loadKeys();
|
||||
}
|
||||
async function loadKeys() {
|
||||
const el = $('#k-list'); if (!el) return;
|
||||
const j = await api('/api/keys');
|
||||
const ks = j.keys || [];
|
||||
el.innerHTML = ks.length ? ks.map(k => keyRowHtml(k)).join('') : `<div class="muted">${t('kEmpty')}</div>`;
|
||||
const ks = (j.keys || []).filter(k => k.role === 'user');
|
||||
el.innerHTML = ks.length ? ks.map(k => keyCanvasHtml(k)).join('') : `<div class="muted">${t('kEmpty')}</div>`;
|
||||
el.querySelectorAll('.kc-blocks').forEach(cv => bindCanvasDrop(cv));
|
||||
el.querySelectorAll('.mb').forEach(b => bindBrickDrag(b));
|
||||
}
|
||||
function keyRowHtml(k) {
|
||||
function keyCanvasHtml(k) {
|
||||
const scopes = k.models || [];
|
||||
return `
|
||||
<div class="key-row">
|
||||
<span class="kr-name">${esc(k.name || '—')}</span>
|
||||
<div class="key-canvas" data-key="${escAttr(k.key)}">
|
||||
<div class="kc-head">
|
||||
<b>${esc(k.name || '—')}</b>
|
||||
${roleTag(k.role)}
|
||||
<span class="kr-key">${esc(maskKey(k.key))}</span>
|
||||
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t('kCopy')}</button>
|
||||
<span class="grow"></span>
|
||||
<span class="muted">${t('kModels')}: ${(k.models && k.models.length) ? k.models.length : t('kAll')}</span>
|
||||
<span class="muted">${fmtCreated(k.created_at)}</span>
|
||||
<button class="ghost small" onclick="toggleScope('${escAttr(k.key)}')">${t('kScope')}</button>
|
||||
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
|
||||
</div>
|
||||
<div id="scope-${escAttr(k.key)}" class="scope-box" style="display:none;padding-left:12px;padding-right:12px">
|
||||
<div class="muted" style="font-size:12px;margin-bottom:8px">${t('kScopeTitle')}</div>
|
||||
<div class="scope-chips">${(k.models && k.models.length)
|
||||
? k.models.map(m => scopeChipHtml(k.key, m)).join('')
|
||||
: `<span class="scope-unlim">${t('kScopeEmpty')}</span>`}</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap">
|
||||
<select class="scope-add" id="add-${escAttr(k.key)}" onchange="scopeAdd('${escAttr(k.key)}', this)">
|
||||
<option value="">${t('kScopeSel')}</option>
|
||||
${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}
|
||||
</select>
|
||||
<button class="ghost small" onclick="scopeCopy('${escAttr(k.key)}')">${t('kCopyList')}</button>
|
||||
<button class="ghost small" onclick="scopePaste('${escAttr(k.key)}')">${t('kPasteList')}</button>
|
||||
<span class="grow"></span>
|
||||
<button class="small" onclick="saveScope('${escAttr(k.key)}')">${t('kSaveScope')}</button>
|
||||
<div class="kc-blocks" data-key="${escAttr(k.key)}">
|
||||
${scopes.length ? '' : `<span class="mb-empty">${t('kEmptyB')}</span>`}
|
||||
${scopes.map(m => scopeHtml(k.key, m)).join('')}
|
||||
<button class="add-brick" onclick="scopePush('${escAttr(k.key)}')">+ ${t('kAddB')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
function scopeChipHtml(key, m) {
|
||||
return `<span class="scope-chip" data-m="${escAttr(m)}" title="${t('kScopeHint')}">${esc(m)}<span class="rm" onclick="scopeRm('${escAttr(key)}', this)">×</span></span>`;
|
||||
function scopeHtml(key, m) {
|
||||
const qt = fmtQuota(m.token_quota);
|
||||
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(m.model)}"
|
||||
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'))}"
|
||||
onclick="scopeEdit('${escAttr(key)}','${escAttr(m.model)}')"
|
||||
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(m.model)}')">
|
||||
<span class="mb-ico">◆</span>
|
||||
<span class="mb-name">${esc(m.model)}</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>`;
|
||||
}
|
||||
function toggleScope(key) {
|
||||
const box = document.getElementById('scope-' + key);
|
||||
if (!box) return;
|
||||
box.style.display = box.style.display === 'none' ? 'block' : 'none';
|
||||
if (box.style.display === 'block') scopeDragInit(key);
|
||||
function fmtQuota(q) { q = +q || 0; if (!q) return '∞';
|
||||
if (q >= 1e9) return (q / 1e9).toFixed(1) + 'B';
|
||||
if (q >= 1e6) return (q / 1e6).toFixed(1) + 'M';
|
||||
if (q >= 1e3) return (q / 1e3).toFixed(1) + 'K';
|
||||
return String(q); }
|
||||
function periodText(p, h) {
|
||||
h = +h || 0;
|
||||
if (p === 'hour') return '·1h';
|
||||
if (p === 'week') return '·7d';
|
||||
if (p === 'month') return '·30d';
|
||||
if (p === 'nhour') return '·' + Math.max(1, h) + 'h';
|
||||
return '';
|
||||
}
|
||||
function scopeModels(key) {
|
||||
const box = document.getElementById('scope-' + key);
|
||||
if (!box) return [];
|
||||
return [...box.querySelectorAll('.scope-chip')].map(x => x.dataset.m).filter(Boolean);
|
||||
function quantBadge(quota, period, hours) {
|
||||
quota = +quota || 0;
|
||||
period = period || '';
|
||||
if (!quota && !period) return '∞';
|
||||
return fmtQuota(quota) + periodText(period, hours);
|
||||
}
|
||||
function scopeDragInit(key) {
|
||||
const box = document.getElementById('scope-' + key);
|
||||
if (!box) return;
|
||||
box.querySelectorAll('.scope-chip').forEach(ch => {
|
||||
ch.setAttribute('draggable', 'true');
|
||||
ch.addEventListener('dragstart', e => { scopeDragEl = ch; ch.classList.add('drag-over'); e.dataTransfer.effectAllowed = 'move'; });
|
||||
ch.addEventListener('dragend', () => { ch.classList.remove('drag-over'); scopeDragEl = null; });
|
||||
ch.addEventListener('dragover', e => e.preventDefault());
|
||||
ch.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
if (!scopeDragEl || scopeDragEl === ch) return;
|
||||
const items = scopeModels(key);
|
||||
const from = items.indexOf(scopeDragEl.dataset.m);
|
||||
const to = items.indexOf(ch.dataset.m);
|
||||
if (from < 0 || to < 0) return;
|
||||
items.splice(to, 0, items.splice(from, 1)[0]);
|
||||
const chips = box.querySelector('.scope-chips');
|
||||
chips.innerHTML = items.map(m => scopeChipHtml(key, m)).join('');
|
||||
scopeDragInit(key);
|
||||
function readScopes(canvas) {
|
||||
return [...canvas.querySelectorAll('.mb')].map(b => ({
|
||||
model: b.dataset.model,
|
||||
token_quota: parseInt(b.dataset.quota) || 0,
|
||||
period: b.dataset.period || '',
|
||||
hours: parseInt(b.dataset.hours) || 0,
|
||||
}));
|
||||
}
|
||||
async function putScope(key, scopes) {
|
||||
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: scopes }) });
|
||||
}
|
||||
async function scopePush(key) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const scopes = readScopes(canvas);
|
||||
if (!scopes.some(s => s.model === 'AUTO')) scopes.push({ model: 'AUTO', token_quota: 0 });
|
||||
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
|
||||
await loadKeys();
|
||||
scopeEdit(key, 'AUTO');
|
||||
}
|
||||
async function scopeDup(key, model) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const scopes = readScopes(canvas);
|
||||
const src = scopes.find(s => s.model === model);
|
||||
if (!src) return;
|
||||
scopes.push({ model: src.model, token_quota: src.token_quota });
|
||||
try { await putScope(key, scopes); toast(t('kDupOK')); } catch (e) { toast(e.message); }
|
||||
}
|
||||
async function scopeRm(key, model) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const scopes = readScopes(canvas).filter(s => s.model !== model);
|
||||
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
|
||||
await loadKeys();
|
||||
toast(t('toastDelOk'));
|
||||
}
|
||||
function scopeEdit(key, model) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const cur = readScopes(canvas).find(s => s.model === model) || { model: '', token_quota: 0 };
|
||||
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';
|
||||
const opts = ['AUTO', ...allModels];
|
||||
if (cur.model && !opts.includes(cur.model)) opts.unshift(cur.model);
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')}</h2>
|
||||
<label>${t('kModelB')}</label>
|
||||
<select id="sc-model">${opts.map(m => `<option value="${escAttr(m)}" ${m === cur.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}</select>
|
||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||
<input id="sc-quota" type="number" min="0" step="1"
|
||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.token_quota ? cur.token_quota : ''}">
|
||||
<label>${t('kPeriodB')}</label>
|
||||
<select id="sc-period">
|
||||
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
|
||||
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
|
||||
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
|
||||
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
|
||||
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
|
||||
</select>
|
||||
<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>
|
||||
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(cur.model)}', this)">${t('kSaveScope')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
$('#sc-period').addEventListener('change', () => {
|
||||
$('#sc-hours').closest('#sc-hours-box').style.display =
|
||||
$('#sc-period').value === 'nhour' ? 'block' : 'none';
|
||||
});
|
||||
$('#sc-model').focus();
|
||||
}
|
||||
async function scopeSave(key, oldModel, btn) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const model = $('#sc-model').value.trim();
|
||||
if (!model) { toast(t('kName')); return; }
|
||||
let q = parseInt($('#sc-quota').value);
|
||||
if (isNaN(q) || isNaN(q) || q < 0) q = 0;
|
||||
let hours = parseInt($('#sc-hours').value);
|
||||
if (isNaN(hours) || hours < 1) hours = 1;
|
||||
const period = $('#sc-period').value;
|
||||
const scopes = readScopes(canvas);
|
||||
const i = scopes.findIndex(s => s.model === oldModel);
|
||||
if (i < 0) scopes.push({ model, token_quota: q, period, hours });
|
||||
else scopes[i] = { model, token_quota: q, period, hours };
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await putScope(key, scopes);
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
toast(t('kSaved'));
|
||||
await loadKeys();
|
||||
} catch (e) { toast(e.message); if (btn) btn.disabled = false; }
|
||||
}
|
||||
function scopeCtx(e, key, model) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
scopeDragEl = null;
|
||||
showCtx(e.clientX, e.clientY, [
|
||||
{ label: t('kEditB'), icon: '✎', fn: () => scopeEdit(key, model) },
|
||||
{ label: t('kDelB'), icon: '✕', danger: true, fn: () => scopeRm(key, model) },
|
||||
]);
|
||||
}
|
||||
let ctxEl = null;
|
||||
function showCtx(x, y, items) {
|
||||
hideCtx();
|
||||
const w = document.createElement('div'); w.id = 'ctx-wrap';
|
||||
w.innerHTML = items.map((it, i) =>
|
||||
`<div class="ctx-itm ${it.danger ? 'danger' : ''}" data-i="${i}"><span class="ic">${esc(it.icon)}</span>${esc(it.label)}</div>`).join('');
|
||||
w.addEventListener('click', e => {
|
||||
const t = e.target.closest('.ctx-itm'); if (!t) return;
|
||||
const it = items[+t.dataset.i]; hideCtx(); if (it) it.fn();
|
||||
});
|
||||
document.body.appendChild(w);
|
||||
const r = w.getBoundingClientRect();
|
||||
w.style.left = Math.max(6, Math.min(x, window.innerWidth - r.width - 6)) + 'px';
|
||||
w.style.top = Math.max(6, Math.min(y, window.innerHeight - r.height - 6)) + 'px';
|
||||
setTimeout(() => document.addEventListener('click', hideCtx2, { once: true }), 10);
|
||||
}
|
||||
function hideCtx2() { hideCtx(); }
|
||||
function hideCtx() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
|
||||
/* cross-canvas brick dragging */
|
||||
function bindBrickDrag(b) {
|
||||
b.addEventListener('dragstart', e => {
|
||||
scopeDragEl = { key: b.dataset.key, model: b.dataset.model };
|
||||
e.dataTransfer.setData('text/plain', b.dataset.model);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
const g = b.cloneNode(true); g.classList.add('mb-ghost');
|
||||
document.body.appendChild(g);
|
||||
b.addEventListener('dragend', () => { g.remove(); scopeDragEl = null; }, { once: true });
|
||||
});
|
||||
b.addEventListener('dragover', e => e.preventDefault());
|
||||
}
|
||||
function bindCanvasDrop(cv) {
|
||||
cv.addEventListener('dragover', e => { e.preventDefault(); cv.classList.add('ovh'); });
|
||||
cv.addEventListener('dragleave', () => cv.classList.remove('ovh'));
|
||||
cv.addEventListener('drop', e => {
|
||||
e.preventDefault(); cv.classList.remove('ovh');
|
||||
if (!scopeDragEl) return;
|
||||
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
|
||||
});
|
||||
}
|
||||
function scopeAdd(key, sel) {
|
||||
const m = sel.value; sel.value = '';
|
||||
if (!m) return;
|
||||
const box = document.getElementById('scope-' + key); if (!box) return;
|
||||
const chips = box.querySelector('.scope-chips');
|
||||
if ([...chips.querySelectorAll('.scope-chip')].some(x => x.dataset.m === m)) return;
|
||||
if (chips.querySelector('.scope-unlim')) chips.innerHTML = '';
|
||||
chips.innerHTML += scopeChipHtml(key, m);
|
||||
scopeDragInit(key);
|
||||
async function moveBrick(from, to, model) {
|
||||
if (from === to) return;
|
||||
const sf = document.querySelector(`.key-canvas[data-key="${CSS.escape(from)}"]`);
|
||||
const st = document.querySelector(`.key-canvas[data-key="${CSS.escape(to)}"]`);
|
||||
if (!sf || !st) return;
|
||||
let fs = readScopes(sf), ts = readScopes(st);
|
||||
const b = fs.find(s => s.model === model);
|
||||
if (!b) return;
|
||||
fs = fs.filter(s => s.model !== model);
|
||||
const dup = ts.some(s => s.model === model);
|
||||
if (!dup) ts.push(b);
|
||||
try {
|
||||
await putScope(from, fs);
|
||||
await putScope(to, ts);
|
||||
toast(t('kMovOK'));
|
||||
} catch (e) { toast(e.message); loadKeys(); }
|
||||
}
|
||||
function scopeRm(key, el) {
|
||||
el.closest('.scope-chip').remove();
|
||||
const box = document.getElementById('scope-' + key);
|
||||
const chips = box ? box.querySelector('.scope-chips') : null;
|
||||
if (chips && !chips.querySelector('.scope-chip')) chips.innerHTML = `<span class="scope-unlim">${t('kScopeEmpty')}</span>`;
|
||||
function openKeyModal() {
|
||||
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:400px;max-width:100%"><h2>${t('kCreate')}</h2>
|
||||
<label>${t('kName')}</label>
|
||||
<input id="kc-name" placeholder="alice">
|
||||
<label>${t('kRole')}</label>
|
||||
<select id="kc-role">
|
||||
<option value="user">${t('kRoleUser')}</option>
|
||||
<option value="admin">${t('kRoleAdmin')}</option>
|
||||
</select>
|
||||
<label>${t('kNote')}</label>
|
||||
<input id="kc-note">
|
||||
<p><button onclick="createKey(this)">${t('kCreateBtn')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
$('#kc-name').focus();
|
||||
}
|
||||
function scopeCopy(key) { copyText(scopeModels(key).join('\n')); }
|
||||
async function scopePaste(key) {
|
||||
let txt = '';
|
||||
try { txt = await navigator.clipboard.readText(); } catch (e) {}
|
||||
if (!txt) txt = prompt(t('kPasteList'));
|
||||
if (!txt) return;
|
||||
const ms = txt.split(/[\n,;\s]+/).map(s => s.trim()).filter(Boolean);
|
||||
const box = document.getElementById('scope-' + key); if (!box) return;
|
||||
const chips = box.querySelector('.scope-chips');
|
||||
chips.innerHTML = ms.map(m => scopeChipHtml(key, m)).join('');
|
||||
scopeDragInit(key);
|
||||
}
|
||||
async function saveScope(key) {
|
||||
const ms = scopeModels(key);
|
||||
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: ms }) });
|
||||
toast(t('kScopeSaved'));
|
||||
loadKeys();
|
||||
}
|
||||
async function createKey() {
|
||||
async function createKey(btn) {
|
||||
const name = $('#kc-name').value.trim();
|
||||
if (!name) { toast(t('kName')); return; }
|
||||
const j = await api('/api/keys', { method: 'POST', body: JSON.stringify({
|
||||
if (btn) btn.disabled = true;
|
||||
let j;
|
||||
try {
|
||||
j = await api('/api/keys', { method: 'POST', body: JSON.stringify({
|
||||
name, role: $('#kc-role').value, note: $('#kc-note').value.trim(),
|
||||
}) });
|
||||
$('#kc-name').value = ''; $('#kc-note').value = '';
|
||||
} catch (e) { toast(e.message); if (btn) btn.disabled = false; return; }
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
$('#k-newbox').innerHTML = `
|
||||
<div class="key-row" style="margin-top:12px;background:var(--card2)">
|
||||
<div class="key-canvas" style="background:var(--card2)">
|
||||
<div class="kc-head">
|
||||
<b>${t('kNewKey')}</b>
|
||||
<span class="kr-key" style="max-width:none">${esc(j.key.key)}</span>
|
||||
<button class="small" onclick="copyText('${escAttr(j.key.key)}')">${t('kCopy')}</button>
|
||||
<span class="grow"></span>
|
||||
<button class="ghost small" onclick="$('#k-newbox').innerHTML=''">×</button>
|
||||
</div>`;
|
||||
</div></div>`;
|
||||
toast(t('kNewOK'));
|
||||
loadKeys();
|
||||
}
|
||||
|
||||
@ -132,6 +132,17 @@ func (r *Registry) AUTOChain() []*Provider {
|
||||
return r.Resolve("AUTO")
|
||||
}
|
||||
|
||||
// ProviderForModel returns the provider owning the model id (nil if unknown).
|
||||
func (r *Registry) ProviderForModel(model string) *Provider {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Default returns the highest-priority available provider.
|
||||
func (r *Registry) Default() *Provider {
|
||||
chain := r.AUTOChain()
|
||||
|
||||
Reference in New Issue
Block a user