mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
feat: AUTO chain rewrite — silent failover+busy skip+pref round-robin+503 tier summary; chain edits reset slot cooldowns (P0/P1); stats by_status + audit jsonl rotation; UI priority-page health badges & status-code card; ctx-menu capture-phase close (outside-press guard); main.go ops warnings; local bundled-Lua verified tests (3 latent bugs fixed); plan.md
This commit is contained in:
@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@ -34,6 +35,21 @@ func main() {
|
||||
log.Fatalf("[llmsproxy] gateway: %v", err)
|
||||
}
|
||||
|
||||
// Ops hygiene: surface the two most common footguns instead of silently
|
||||
// running with them.
|
||||
if keys := c.GatewayKeys(); len(keys) == 0 {
|
||||
log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected")
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
if k == "sk-gw-local-0001" || k == "sk-local-0001" {
|
||||
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if l := c.Listen(); strings.HasPrefix(l, "0.0.0.0:") || strings.HasPrefix(l, "::") {
|
||||
log.Printf("[llmsproxy] WARNING: listen=%s binds ALL interfaces — bind an internal address in production", l)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: c.Listen(),
|
||||
Handler: gw.Handler(),
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@ -100,7 +101,15 @@ func buildBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "llmsproxy")
|
||||
if runtime.GOOS == "windows" {
|
||||
bin += ".exe" // go build writes the exact name; Windows needs the suffix to exec
|
||||
}
|
||||
out, err := exec.Command("go", "build", "-tags", "luajit", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
|
||||
if err != nil && runtime.GOOS == "windows" {
|
||||
// local Windows dev box may lack LuaJIT: fall back to the bundled
|
||||
// Lua runtime (the e2e adapters used here are passthrough-only)
|
||||
out, err = exec.Command("go", "build", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v\n%s", err, out)
|
||||
}
|
||||
@ -166,7 +175,8 @@ func (g *gatewayUnderTest) do(method, path string, body string, authed bool) (*h
|
||||
}
|
||||
|
||||
// writeConfig writes a temp gateway config pointing at the mock upstreams,
|
||||
// listening on the given address.
|
||||
// listening on the given address. Every non-image upstream becomes a chat
|
||||
// source; the first named one ("good") is the highest-priority AUTO slot.
|
||||
func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*mockUpstream) string {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
@ -176,7 +186,8 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc
|
||||
sb.WriteString("adapter_dir: " + filepath.Join(dir, "adapters") + "\n")
|
||||
sb.WriteString("runtime_file: " + filepath.Join(dir, "runtime.json") + "\n")
|
||||
sb.WriteString("sources:\n")
|
||||
order := []string{"good", "image"}
|
||||
order := []string{"good", "fallback", "image"}
|
||||
prio := map[string]int{"good": 100, "fallback": 50}
|
||||
for _, name := range order {
|
||||
u, ok := upstreams[name]
|
||||
if !ok {
|
||||
@ -185,7 +196,7 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc
|
||||
if name == "image" {
|
||||
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n priority: 80\n")
|
||||
} else {
|
||||
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n priority: 100\n")
|
||||
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n priority: " + fmt.Sprint(prio[name]) + "\n")
|
||||
}
|
||||
}
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
@ -197,8 +208,9 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc
|
||||
|
||||
func TestEndToEnd(t *testing.T) {
|
||||
upstreams := map[string]*mockUpstream{
|
||||
"good": newMockUpstream(t),
|
||||
"image": newMockUpstream(t),
|
||||
"good": newMockUpstream(t),
|
||||
"fallback": newMockUpstream(t),
|
||||
"image": newMockUpstream(t),
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
@ -279,6 +291,35 @@ func TestEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndToEndAuto503: with every AUTO slot failing, the gateway must answer
|
||||
// 503 whose message summarizes each failed tier/source/model instead of a
|
||||
// bare "no provider available" (P8).
|
||||
func TestEndToEndAuto503(t *testing.T) {
|
||||
upstreams := map[string]*mockUpstream{
|
||||
"good": newMockUpstream(t),
|
||||
}
|
||||
dir := t.TempDir()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("pick port: %v", err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
cfg := writeConfig(t, dir, addr, upstreams)
|
||||
bin := buildBinary(t)
|
||||
g := startGateway(t, bin, addr, cfg)
|
||||
|
||||
upstreams["good"].SetFail(true)
|
||||
resp, body := g.do("POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`, true)
|
||||
if resp == nil || resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%v body=%q", statusOf(resp), body)
|
||||
}
|
||||
if !strings.Contains(body, "all auto tiers failed") || !strings.Contains(body, "good/good-m") {
|
||||
t.Fatalf("503 must summarize the failed slot, body=%q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func statusOf(resp *http.Response) int {
|
||||
if resp == nil {
|
||||
return -1
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
@ -26,6 +27,7 @@ type Core struct {
|
||||
store *config.Store
|
||||
scheduler *scheduler.Scheduler
|
||||
registry *provider.Registry
|
||||
autoChain atomic.Pointer[scheduler.Chain]
|
||||
}
|
||||
|
||||
// New builds the core from a config file plus runtime overlay.
|
||||
@ -138,6 +140,11 @@ func (c *Core) Scheduler() *scheduler.Scheduler { return c.scheduler }
|
||||
|
||||
func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
|
||||
// AutoChain returns the current AUTO scheduling chain (immutable after build;
|
||||
// a rebuilt chain is swapped in atomically). nil before the first build or
|
||||
// when no auto slots could be resolved.
|
||||
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
|
||||
|
||||
func (c *Core) DefaultModel() string { return c.cfg.DefaultModel }
|
||||
|
||||
func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys }
|
||||
@ -228,9 +235,34 @@ func cleanScopes(entries []config.ModelScope) []config.ModelScope {
|
||||
return clean
|
||||
}
|
||||
|
||||
// SaveAutoRules persists the AUTO scheduling slots.
|
||||
// SaveAutoRules persists the AUTO scheduling slots, rebuilds the chain and
|
||||
// clears the cooldown of every slot in it — preference scores are kept, so a
|
||||
// reliably good model keeps its edge while an edited chain applies
|
||||
// immediately. Providers are NOT rebuilt here (their per-model state survives
|
||||
// the edit, plan 2.4 lifecycle); rebuildRegistry covers source edits.
|
||||
func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
||||
return c.store.SaveAutoRules(cleanScopes(entries))
|
||||
if err := c.store.SaveAutoRules(cleanScopes(entries)); err != nil {
|
||||
return err
|
||||
}
|
||||
c.buildAutoChain()
|
||||
if ch := c.autoChain.Load(); ch != nil {
|
||||
for _, tn := range ch.Tiers {
|
||||
for _, sl := range tn.Slots {
|
||||
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
|
||||
p.ResetModelCooldown(sl.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetHealth clears the scheduling backoff state of every provider (admin
|
||||
// UI action). Unlike SaveAutoRules this does not touch the chain itself.
|
||||
func (c *Core) ResetHealth() {
|
||||
for _, p := range c.registry.Providers() {
|
||||
p.ResetHealth()
|
||||
}
|
||||
}
|
||||
|
||||
// Registry resolves model -> owning provider.
|
||||
@ -303,9 +335,88 @@ func (c *Core) rebuildRegistry() error {
|
||||
} else {
|
||||
c.registry.Replace(providers)
|
||||
}
|
||||
c.buildAutoChain()
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAutoChain rebuilds the AUTO chain snapshot from the persisted rules
|
||||
// against the current providers. Slots whose (model, source) no longer exists
|
||||
// and image-kind models are dropped; a chain with no slots makes AUTO
|
||||
// requests answer "no auto slot configured".
|
||||
func (c *Core) buildAutoChain() {
|
||||
prov := func(model, source string) scheduler.Provider {
|
||||
p := c.registry.ProviderForSlot(model, source)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
rules := c.store.AutoRules()
|
||||
sr := make([]scheduler.Rule, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
r := scheduler.Rule{
|
||||
Model: e.Model,
|
||||
Source: e.Source,
|
||||
Tier: e.Tier,
|
||||
Quota: e.TokenQuota,
|
||||
Period: e.Period,
|
||||
Hours: e.Hours,
|
||||
}
|
||||
if r.Source == "" {
|
||||
// canonicalize to the owning source so summaries/audit/quota
|
||||
// windows always carry a real source name
|
||||
if p := c.registry.ProviderForSlot(e.Model, ""); p != nil {
|
||||
r.Source = p.Name()
|
||||
}
|
||||
}
|
||||
sr = append(sr, r)
|
||||
}
|
||||
c.autoChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
|
||||
// AutoSlotState is the UI-facing health snapshot of one AUTO chain slot.
|
||||
type AutoSlotState struct {
|
||||
Model string `json:"model"`
|
||||
Source string `json:"source"`
|
||||
Pref int64 `json:"pref"`
|
||||
FailCount int64 `json:"fail_count"`
|
||||
CooldownUntil int64 `json:"cooldown_until"`
|
||||
Cooling bool `json:"cooling"`
|
||||
}
|
||||
|
||||
// AutoSlotStates returns per-slot health (preference, failure count,
|
||||
// cooldown) for every slot of the current AUTO chain, mirroring the chain
|
||||
// order so the priority-page UI can annotate its blocks.
|
||||
func (c *Core) AutoSlotStates() []AutoSlotState {
|
||||
ch := c.autoChain.Load()
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
var out []AutoSlotState
|
||||
for _, tn := range ch.Tiers {
|
||||
for _, sl := range tn.Slots {
|
||||
pp, ok := sl.Prov.(*provider.Provider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pref, fail, until := pp.ModelHealthInfo(sl.Model)
|
||||
out = append(out, AutoSlotState{
|
||||
Model: sl.Model,
|
||||
Source: sl.Source,
|
||||
Pref: pref,
|
||||
FailCount: fail,
|
||||
CooldownUntil: until,
|
||||
Cooling: until > now,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Reload re-reads the runtime store and rebuilds sources (adapter reload is not
|
||||
// strictly needed since adapters are loaded into the VM at startup; uploaded
|
||||
// adapters are placed in the adapter dir and loaded by the web UI).
|
||||
@ -408,4 +519,4 @@ func (c *Core) Close() {
|
||||
if c.vm != nil {
|
||||
c.vm.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -17,15 +18,15 @@ import (
|
||||
|
||||
// chatRequest mirrors the OpenAI chat completions request the gateway accepts.
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []types.ChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []interface{} `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
DisableThinking bool `json:"disable_thinking"`
|
||||
ExtraBody map[string]interface{} `json:"extra_body,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []types.ChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []interface{} `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
DisableThinking bool `json:"disable_thinking"`
|
||||
ExtraBody map[string]interface{} `json:"extra_body,omitempty"`
|
||||
}
|
||||
|
||||
// ChatCompletion is the non-streaming OpenAI response object.
|
||||
@ -60,9 +61,9 @@ type ChatChunk struct {
|
||||
}
|
||||
|
||||
type ChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta RespMessage `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
Index int `json:"index"`
|
||||
Delta RespMessage `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
var seq int64
|
||||
@ -279,9 +280,9 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
model = g.core.DefaultModel()
|
||||
}
|
||||
if isAuto(model) {
|
||||
plans := g.autoPlans()
|
||||
if len(plans) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot available (quota exhausted or none configured)")
|
||||
chain := g.core.AutoChain()
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
|
||||
return
|
||||
}
|
||||
if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" {
|
||||
@ -306,12 +307,21 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
Type: "chat",
|
||||
OK: false,
|
||||
}
|
||||
// quotaExhausted reports a slot whose token window has been used up;
|
||||
// exhausted slots are dropped from scheduling without penalty.
|
||||
quotaExhausted := func(sl *scheduler.Slot) bool {
|
||||
if sl.Quota <= 0 {
|
||||
return false
|
||||
}
|
||||
win := AutoPeriodSeconds(sl.Period, sl.Hours)
|
||||
return g.stats.WindowTokens(sl.Model, sl.Source, win) >= sl.Quota
|
||||
}
|
||||
if req.Stream {
|
||||
rec.Type = "stream"
|
||||
g.streamChatAuto(w, ctx, plans, inner, rec)
|
||||
g.streamChatAuto(w, ctx, chain, inner, rec, quotaExhausted)
|
||||
return
|
||||
}
|
||||
g.singleChatAuto(w, ctx, plans, inner, rec)
|
||||
g.singleChatAuto(w, ctx, chain, inner, rec, quotaExhausted)
|
||||
return
|
||||
}
|
||||
if !isAuto(model) {
|
||||
@ -322,7 +332,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cands, effective := g.resolveCands(r.Context(), &req)
|
||||
if len(cands) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no LLM source configured")
|
||||
writeError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("model %q is not configured", model))
|
||||
return
|
||||
}
|
||||
if effective == "" {
|
||||
@ -472,17 +482,43 @@ func estimateTextTokens(parts ...interface{}) int64 {
|
||||
return int64(n/3 + 1)
|
||||
}
|
||||
|
||||
// toScheduler adapts concrete providers to the scheduler.Provider interface.
|
||||
// It lives here (not in the scheduler package) so scheduler tests do not pull
|
||||
// in the provider package and with it the Lua runtime's link requirements.
|
||||
func toScheduler(cands []*provider.Provider) []scheduler.Provider {
|
||||
out := make([]scheduler.Provider, len(cands))
|
||||
for i, p := range cands {
|
||||
out[i] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upstreamErrStatus maps a scheduling error to its HTTP status: a failed
|
||||
// AUTO chain answers 503 with its per-tier summary, a busy source (every
|
||||
// concurrency slot in use) is a transient capacity condition answered with
|
||||
// 429 so clients fail fast, while other upstream failures stay 502.
|
||||
func upstreamErrStatus(err error) int {
|
||||
var ce *scheduler.ChainErr
|
||||
if errors.As(err, &ce) {
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
if errors.Is(err, provider.ErrBusy) {
|
||||
return http.StatusTooManyRequests
|
||||
}
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req)
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, toScheduler(cands), req)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
|
||||
writeError(w, rec.Status, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
rec.OK = true
|
||||
@ -538,12 +574,12 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
g.writeRec(rec)
|
||||
}()
|
||||
chunks, _, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry(cands), req)
|
||||
chunks, _, usedModel, err := g.core.Scheduler().ChatStream(ctx, toScheduler(cands), req)
|
||||
if err != nil {
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
|
||||
writeError(w, rec.Status, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
if usedModel != "" {
|
||||
@ -611,146 +647,66 @@ 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
|
||||
tier int
|
||||
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. Slots are returned
|
||||
// tiered high→low, and within the same tier the order is rotated round-robin
|
||||
// so concurrent requests spread evenly across equal-priority sources (still
|
||||
// with failover to the next slot if one errors).
|
||||
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.ProviderForSlot(e.Model, e.Source)
|
||||
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, e.Source, win) >= e.TokenQuota {
|
||||
continue
|
||||
}
|
||||
plans = append(plans, autoPlan{p: p, model: e.Model, tier: e.Tier, quota: e.TokenQuota, win: win})
|
||||
}
|
||||
return g.rotateSameTier(plans)
|
||||
}
|
||||
|
||||
// rotateSameTier reorders the leading plan of each consecutive same-tier run
|
||||
// using a global round-robin counter, so requests distribute across
|
||||
// equal-priority sources while preserving tier ordering and in-tier failover.
|
||||
func (g *Gateway) rotateSameTier(plans []autoPlan) []autoPlan {
|
||||
if len(plans) < 2 {
|
||||
// allow single slot without varying
|
||||
return plans
|
||||
}
|
||||
rot := int(g.autoRR.Add(1))
|
||||
out := make([]autoPlan, 0, len(plans))
|
||||
for i := 0; i < len(plans); {
|
||||
j := i
|
||||
for j < len(plans) && plans[j].tier == plans[i].tier {
|
||||
j++
|
||||
}
|
||||
run := plans[i:j]
|
||||
if len(run) > 1 {
|
||||
off := rot % len(run)
|
||||
run = append(run[off:], run[:off]...)
|
||||
}
|
||||
out = append(out, run...)
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// singleChatAuto runs a non-streaming AUTO request down the chain (see
|
||||
// scheduler.ChainChat): tiers descending, per-tier round-robin ordered by
|
||||
// preference, cooldown as the only hard skip, busy slots skipped without
|
||||
// penalty and a bounded busy wait. When every tier fails, the response is a
|
||||
// 503 carrying the per-tier error summary (which source/model failed why).
|
||||
func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, chain *scheduler.Chain, req *types.ChatRequest, rec *Req, quotaExhausted func(*scheduler.Slot) bool) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
var lastErr error
|
||||
var lastSrc, lastModel string
|
||||
for _, pl := range plans {
|
||||
if !pl.p.Available() {
|
||||
continue
|
||||
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainChat(ctx, chain, req, quotaExhausted)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.OK = false
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
if ce, ok := err.(*scheduler.ChainErr); ok && len(ce.Tiers) > 0 {
|
||||
rec.Source = ce.Tiers[0].Source
|
||||
rec.Model = ce.Tiers[0].Model
|
||||
}
|
||||
r := *req
|
||||
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)
|
||||
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)
|
||||
if rec.Prompt == 0 {
|
||||
rec.Prompt = estimatePromptTokens(&r)
|
||||
}
|
||||
rec.Compl = int64(resp.TokenUsage.Completion)
|
||||
if rec.Compl == 0 {
|
||||
rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls)
|
||||
}
|
||||
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)
|
||||
writeError(w, rec.Status, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no provider available")
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
rec.Prompt = int64(resp.TokenUsage.Prompt)
|
||||
if rec.Prompt == 0 {
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
}
|
||||
rec.OK = false
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Err = lastErr.Error()
|
||||
if rec.Model == "" {
|
||||
rec.Model = lastModel
|
||||
}
|
||||
if rec.Source == "" {
|
||||
rec.Source = lastSrc
|
||||
rec.Compl = int64(resp.TokenUsage.Completion)
|
||||
if rec.Compl == 0 {
|
||||
rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls)
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
rec.Model = usedModel
|
||||
g.writeRec(rec)
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", lastErr.Error())
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// streamChatAuto streams an AUTO request down the chain. A slot is abandoned
|
||||
// only before its first chunk (connect error / non-200 / busy); once a stream
|
||||
// starts it stays pinned. Total failure writes a JSON 503 (with the per-tier
|
||||
// summary) before any SSE byte is sent.
|
||||
func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, chain *scheduler.Chain, req *types.ChatRequest, rec *Req, quotaExhausted func(*scheduler.Slot) bool) {
|
||||
rec.LatMs = 0
|
||||
t0 := time.Now()
|
||||
rec.OK = true
|
||||
@ -759,6 +715,23 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
g.writeRec(rec)
|
||||
}()
|
||||
chunks, usedSrc, usedModel, err := g.core.Scheduler().ChainChatStream(ctx, chain, req, quotaExhausted)
|
||||
if err != nil {
|
||||
rec.OK = false
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
if ce, ok := err.(*scheduler.ChainErr); ok && len(ce.Tiers) > 0 {
|
||||
rec.Source = ce.Tiers[0].Source
|
||||
rec.Model = ce.Tiers[0].Model
|
||||
}
|
||||
writeError(w, rec.Status, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
@ -780,68 +753,38 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla
|
||||
return true
|
||||
}
|
||||
if !send(ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: "auto",
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
|
||||
}) {
|
||||
return
|
||||
}
|
||||
var lastErr error
|
||||
var lastSrc, lastModel string
|
||||
for _, pl := range plans {
|
||||
if !pl.p.Available() {
|
||||
continue
|
||||
for ck := range chunks {
|
||||
chunk := ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||
}
|
||||
r := *req
|
||||
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)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
delta := RespMessage{Role: "assistant", Content: ck.Content}
|
||||
if ck.ReasoningContent != "" {
|
||||
delta.ReasoningContent = ck.ReasoningContent
|
||||
}
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel
|
||||
rec.Source = usedSrc
|
||||
if len(ck.ToolCalls) > 0 {
|
||||
delta.ToolCalls = ck.ToolCalls
|
||||
}
|
||||
rec.Prompt = estimatePromptTokens(&r)
|
||||
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)+len(ck.ToolCalls)) / 3
|
||||
if !send(chunk) {
|
||||
return
|
||||
}
|
||||
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)+len(ck.ToolCalls)) / 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()
|
||||
if rec.Model == "" {
|
||||
rec.Model = lastModel
|
||||
}
|
||||
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)
|
||||
stop := "stop"
|
||||
send(ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
|
||||
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
|
||||
})
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@ -889,13 +832,13 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
defer done()
|
||||
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, Source: firstSource(cands), OK: false}
|
||||
t0 := time.Now()
|
||||
resp, usedSrc, err := g.core.Scheduler().Image(r.Context(), scheduler.FromRegistry(cands), &req)
|
||||
resp, usedSrc, err := g.core.Scheduler().Image(r.Context(), toScheduler(cands), &req)
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
rec.Status = http.StatusBadGateway
|
||||
rec.Status = upstreamErrStatus(err)
|
||||
rec.Err = err.Error()
|
||||
g.writeRec(rec)
|
||||
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
|
||||
writeError(w, rec.Status, "upstream_error", err.Error())
|
||||
return
|
||||
}
|
||||
if usedSrc != "" {
|
||||
@ -909,4 +852,4 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
Created: time.Now().Unix(),
|
||||
Data: resp.ImageData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
|
||||
cfg := &config.Config{
|
||||
AdapterDir: filepath.Join(t.TempDir(), "adapters"),
|
||||
RuntimeFile: filepath.Join(t.TempDir(), "runtime.json"),
|
||||
GatewayKeys: []string{"sk-test"},
|
||||
Sources: srcs,
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
@ -69,6 +70,192 @@ func doReq(t *testing.T, g *Gateway, method, path, body string) *httptest.Respon
|
||||
return rr
|
||||
}
|
||||
|
||||
// upstreamCtrl toggles a mocked upstream's behavior between requests.
|
||||
type upstreamCtrl struct {
|
||||
status int // 0 = healthy; else every request fails with that status
|
||||
hits int // chat call count
|
||||
}
|
||||
|
||||
// upstream returns a mocked OpenAI upstream driven by ctrl.status.
|
||||
func upstream(t *testing.T, ctrl *upstreamCtrl) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctrl.hits++
|
||||
if ctrl.status != 0 {
|
||||
w.WriteHeader(ctrl.status)
|
||||
fmt.Fprint(w, `{"error":"boom"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
||||
}))
|
||||
}
|
||||
|
||||
// TestChatAutoChainTierFailover: AUTO chain, first slot hard-fails, the pass
|
||||
// moves on within the same tier and the request is served by the next slot.
|
||||
func TestChatAutoChainTierFailover(t *testing.T) {
|
||||
a, b := &upstreamCtrl{status: 500}, &upstreamCtrl{}
|
||||
aUp := upstream(t, a)
|
||||
bUp := upstream(t, b)
|
||||
defer aUp.Close()
|
||||
defer bUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var cc ChatCompletion
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &cc)
|
||||
if cc.Model != "b-m" {
|
||||
t.Fatalf("AUTO served %q, want b-m", cc.Model)
|
||||
}
|
||||
if a.hits == 0 || b.hits == 0 {
|
||||
t.Fatalf("hit counts a=%d b=%d, want both > 0", a.hits, b.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutoChain503Summary: every AUTO slot fails -> 503 whose message
|
||||
// names each failed tier/source/model.
|
||||
func TestChatAutoChain503Summary(t *testing.T) {
|
||||
a, b := &upstreamCtrl{status: 500}, &upstreamCtrl{status: 500}
|
||||
aUp := upstream(t, a)
|
||||
bUp := upstream(t, b)
|
||||
defer aUp.Close()
|
||||
defer bUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "all auto tiers failed") ||
|
||||
!strings.Contains(rr.Body.String(), "a/a-m") ||
|
||||
!strings.Contains(rr.Body.String(), "b/b-m") {
|
||||
t.Fatalf("503 must summarize every tier, body=%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutoQuotaSkip: a slot whose token quota is exhausted is dropped
|
||||
// from scheduling; with no other slot the chain answers 503 naming the quota.
|
||||
func TestChatAutoQuotaSkip(t *testing.T) {
|
||||
ctrl := &upstreamCtrl{}
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
// one slot with an hourly quota of 1 token
|
||||
rr := doReq(t, g, "PUT", "/api/auto",
|
||||
`{"rules":[{"model":"a-m","tier":0,"token_quota":1,"period":"hour"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("put auto status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// first request consumes 4 tokens -> quota exhausted
|
||||
rr = doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("first status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// second request must skip the exhausted slot and fail 503
|
||||
rr = doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("quota status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "quota exhausted") {
|
||||
t.Fatalf("503 must name the quota reason, body=%s", rr.Body.String())
|
||||
}
|
||||
if ctrl.hits != 1 {
|
||||
t.Fatalf("upstream hits = %d, want 1 (exhausted slot must not be called)", ctrl.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoStatesReportChainHealth: GET /api/auto reports per-slot health for
|
||||
// the priority-page UI; a chain edit resets the failure state to zero.
|
||||
func TestAutoStatesReportChainHealth(t *testing.T) {
|
||||
ctrl := &upstreamCtrl{status: 500}
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
|
||||
fetch := func() []core.AutoSlotState {
|
||||
rr := doReq(t, g, "GET", "/api/auto", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("get auto status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Rules []config.ModelScope `json:"rules"`
|
||||
States []core.AutoSlotState `json:"states"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return body.States
|
||||
}
|
||||
|
||||
st := fetch()
|
||||
if len(st) != 1 || st[0].Model != "a-m" || st[0].Source != "a" {
|
||||
t.Fatalf("want 1 slot a/a-m, got %#v", st)
|
||||
}
|
||||
if st[0].FailCount == 0 || !st[0].Cooling {
|
||||
t.Fatalf("slot must report the failure (fail=%d cooling=%v)", st[0].FailCount, st[0].Cooling)
|
||||
}
|
||||
|
||||
doReq(t, g, "PUT", "/api/auto",
|
||||
`{"rules":[{"model":"a-m","tier":0}]}`)
|
||||
st = fetch()
|
||||
if st[0].FailCount != 0 || st[0].Cooling {
|
||||
t.Fatalf("edit must reset health, got %#v", st[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveResetsCooldown: editing the AUTO chain clears the cooldown of
|
||||
// its slots, so a fixed upstream is schedulable again without waiting (P1).
|
||||
func TestAutoSaveResetsCooldown(t *testing.T) {
|
||||
ctrl := &upstreamCtrl{status: 500}
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expect 503 while upstream down, got %d", rr.Code)
|
||||
}
|
||||
p := g.core.ProviderForSlot("a-m", "a")
|
||||
if p == nil || p.ModelAvailable("a-m") {
|
||||
t.Fatal("a-m must be cooling after the failure")
|
||||
}
|
||||
// editing the chain (same rules) must clear the cooldown immediately
|
||||
rr = doReq(t, g, "PUT", "/api/auto",
|
||||
`{"rules":[{"model":"a-m","tier":0}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("put auto status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !p.ModelAvailable("a-m") {
|
||||
t.Fatal("SaveAutoRules must reset the slot cooldown")
|
||||
}
|
||||
// healed upstream -> AUTO serves again on the next request
|
||||
ctrl.status = 0
|
||||
rr = doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("AUTO after reset status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSingle(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
@ -446,4 +633,4 @@ func TestAPIChatInternal(t *testing.T) {
|
||||
if !strings.Contains(rr.Body.String(), "pong") {
|
||||
t.Fatalf("api chat body=%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,7 +142,10 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
||||
// 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()})
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"rules": g.core.AutoRules(),
|
||||
"states": g.core.AutoSlotStates(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
|
||||
@ -15,7 +15,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
@ -27,12 +26,11 @@ var uiFS embed.FS
|
||||
|
||||
// Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI.
|
||||
type Gateway struct {
|
||||
core *core.Core
|
||||
ui http.Handler
|
||||
stats *Stats
|
||||
probeMu sync.Mutex
|
||||
lastProbe time.Time
|
||||
autoRR atomic.Uint64
|
||||
core *core.Core
|
||||
ui http.Handler
|
||||
stats *Stats
|
||||
probeMu sync.Mutex
|
||||
lastProbe time.Time
|
||||
}
|
||||
|
||||
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
@ -131,6 +129,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleChat(w, r)
|
||||
case r.URL.Path == "/api/status":
|
||||
g.handleStatusAPI(w, r)
|
||||
case r.URL.Path == "/api/status/reset":
|
||||
g.handleResetHealth(w, r)
|
||||
case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"):
|
||||
g.handleStatsAPI(w, r)
|
||||
case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"):
|
||||
@ -389,6 +389,22 @@ func (g *Gateway) ensureProbe(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleResetHealth (admin) clears the per-source backoff state so a fixed
|
||||
// upstream or an edited AUTO priority chain becomes schedulable immediately.
|
||||
func (g *Gateway) handleResetHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
||||
return
|
||||
}
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
g.core.ResetHealth()
|
||||
g.stats.AppendAudit("config", map[string]interface{}{"action": "reset_health", "key": keyID(reqKey(r.Context()))})
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
g.ensureProbe(r.Context())
|
||||
host := r.Host
|
||||
|
||||
@ -3,7 +3,11 @@ package gateway
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@ -56,6 +60,7 @@ type Stats struct {
|
||||
bySrc map[string]*Stat
|
||||
byKeyModel map[string]map[string]*Stat
|
||||
byKeySrc map[string]map[string]*Stat
|
||||
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
|
||||
recs []Req
|
||||
maxRecs int
|
||||
auditPath string
|
||||
@ -64,6 +69,15 @@ type Stats struct {
|
||||
|
||||
const hourSec = 3600
|
||||
|
||||
// auditRotateBytes rotates the audit file once it grows past this size (the
|
||||
// file is renamed to <path>.<unix>.old and a fresh one is started); pruning
|
||||
// keeps at most auditKeepOld rotated files. Both are vars so tests can shrink
|
||||
// the threshold.
|
||||
var (
|
||||
auditRotateBytes int64 = 64 << 20
|
||||
auditKeepOld = 10
|
||||
)
|
||||
|
||||
func NewStats(maxRecords int) *Stats {
|
||||
if maxRecords <= 0 {
|
||||
maxRecords = 3000
|
||||
@ -74,6 +88,7 @@ func NewStats(maxRecords int) *Stats {
|
||||
bySrc: map[string]*Stat{},
|
||||
byKeyModel: map[string]map[string]*Stat{},
|
||||
byKeySrc: map[string]map[string]*Stat{},
|
||||
byStatus: map[int]*Stat{},
|
||||
modelHour: map[string]map[int64]int64{},
|
||||
maxRecs: maxRecords,
|
||||
}
|
||||
@ -98,6 +113,10 @@ func inc(m map[string]*Stat, name string, r Req) {
|
||||
a = &Stat{}
|
||||
m[name] = a
|
||||
}
|
||||
incStatus(a, name, r)
|
||||
}
|
||||
|
||||
func incStatus(a *Stat, name string, r Req) {
|
||||
a.Reqs++
|
||||
if r.OK {
|
||||
a.OK++
|
||||
@ -160,6 +179,15 @@ func (s *Stats) Record(r Req) {
|
||||
s.byKeySrc[r.Key] = ks
|
||||
}
|
||||
inc(ks, r.Source, r)
|
||||
if r.Status > 0 {
|
||||
name := strconv.Itoa(r.Status)
|
||||
a := s.byStatus[r.Status]
|
||||
if a == nil {
|
||||
a = &Stat{}
|
||||
s.byStatus[r.Status] = a
|
||||
}
|
||||
incStatus(a, name, r)
|
||||
}
|
||||
// window bucket for quota enforcement (per source-model pair, per unix hour)
|
||||
tok := r.Prompt + r.Compl
|
||||
if tok > 0 && r.Model != "" {
|
||||
@ -187,28 +215,32 @@ func (s *Stats) Record(r Req) {
|
||||
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()
|
||||
s.rotateAuditLocked()
|
||||
appendAuditLine(s.auditPath, r)
|
||||
}
|
||||
}
|
||||
|
||||
// rotateAuditLocked renames the audit file to <path>.<unix>.old once it
|
||||
// exceeds auditRotateBytes and prunes old files beyond auditKeepOld, keeping
|
||||
// the newest ones. Caller must hold s.mu.
|
||||
func (s *Stats) rotateAuditLocked() {
|
||||
if s.auditPath == "" || auditRotateBytes <= 0 {
|
||||
return
|
||||
}
|
||||
if fi, err := os.Stat(s.auditPath); err == nil && fi.Size() < auditRotateBytes {
|
||||
return
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
if os.Rename(s.auditPath, fmt.Sprintf("%s.%d.old", s.auditPath, ts)) == nil {
|
||||
old, _ := filepath.Glob(s.auditPath + ".*.old")
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(old)))
|
||||
for i := auditKeepOld; i < len(old); i++ {
|
||||
_ = os.Remove(old[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendAudit writes a generic event line (access log entry, login event,
|
||||
// config change, …) to the same audit file without touching the aggregates.
|
||||
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
|
||||
s.mu.Lock()
|
||||
path := s.auditPath
|
||||
s.mu.Unlock()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
|
||||
for k, v := range data {
|
||||
row[k] = v
|
||||
}
|
||||
func appendAuditLine(path string, row interface{}) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
@ -219,6 +251,22 @@ func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// AppendAudit writes a generic event line (access log entry, login event,
|
||||
// config change, …) to the same audit file without touching the aggregates.
|
||||
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
|
||||
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
|
||||
for k, v := range data {
|
||||
row[k] = v
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.auditPath == "" {
|
||||
return
|
||||
}
|
||||
s.rotateAuditLocked()
|
||||
appendAuditLine(s.auditPath, row)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -376,12 +424,22 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
total.LatMax = a.LatMax
|
||||
}
|
||||
}
|
||||
bs := make([]agrRow, 0, len(s.byStatus))
|
||||
for code := range s.byStatus {
|
||||
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
|
||||
}
|
||||
sort.Slice(bs, func(i, j int) bool {
|
||||
ci, _ := strconv.Atoi(bs[i].Name)
|
||||
cj, _ := strconv.Atoi(bs[j].Name)
|
||||
return ci < cj
|
||||
})
|
||||
return map[string]interface{}{
|
||||
"active": s.active,
|
||||
"total": total,
|
||||
"by_key": rows(byKey),
|
||||
"by_model": rows(byModel),
|
||||
"by_source": rows(bySrc),
|
||||
"by_status": bs,
|
||||
"records": append([]Req(nil), recs...),
|
||||
}
|
||||
}
|
||||
88
internal/gateway/stats_test.go
Normal file
88
internal/gateway/stats_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatsByStatus(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
s.Record(Req{Key: "k", Model: "m", Source: "s", Status: 200, OK: true})
|
||||
s.Record(Req{Key: "k", Model: "m", Source: "s", Status: 402, OK: false})
|
||||
s.Record(Req{Key: "k", Model: "m", Source: "s", Status: 400, OK: false})
|
||||
snap := s.Snapshot(0, "")
|
||||
bs, ok := snap["by_status"].([]agrRow)
|
||||
if !ok {
|
||||
t.Fatalf("by_status missing: %#v", snap["by_status"])
|
||||
}
|
||||
if len(bs) != 3 {
|
||||
t.Fatalf("want 3 status buckets, got %d: %#v", len(bs), bs)
|
||||
}
|
||||
if bs[0].Name != "200" || bs[0].OK != 1 || bs[0].Err != 0 {
|
||||
t.Fatalf("bucket 200 wrong: %#v", bs[0])
|
||||
}
|
||||
if bs[1].Name != "400" || bs[1].Err != 1 {
|
||||
t.Fatalf("bucket 400 wrong: %#v", bs[1])
|
||||
}
|
||||
if bs[2].Name != "402" || bs[2].Err != 1 {
|
||||
t.Fatalf("bucket 402 wrong: %#v", bs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
s := NewStats(10)
|
||||
s.LoadAudit(path)
|
||||
|
||||
oldRotate, oldKeep := auditRotateBytes, auditKeepOld
|
||||
auditRotateBytes, auditKeepOld = 64, 10
|
||||
defer func() { auditRotateBytes, auditKeepOld = oldRotate, oldKeep }()
|
||||
|
||||
oldFiles := func() []string {
|
||||
matches, _ := filepath.Glob(path + ".*.old")
|
||||
return matches
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
s.AppendAudit("ev", map[string]interface{}{"i": i})
|
||||
}
|
||||
if got := len(oldFiles()); got != 1 {
|
||||
t.Fatalf("want 1 rotated file after first overflow, got %d", got)
|
||||
}
|
||||
if b, err := os.ReadFile(path); err != nil || len(b) == 0 {
|
||||
t.Fatalf("active audit file must continue appending: %v %d bytes", err, len(b))
|
||||
}
|
||||
|
||||
// seed 12 fake old files; the next rotation must prune back to keep=10
|
||||
for i := 1; i <= 12; i++ {
|
||||
name := fmt.Sprintf("%s.%010d.old", path, i)
|
||||
_ = os.WriteFile(name, []byte("x\n"), 0644)
|
||||
}
|
||||
s.AppendAudit("ev", map[string]interface{}{"i": 98})
|
||||
s.AppendAudit("ev", map[string]interface{}{"i": 99})
|
||||
if got := len(oldFiles()); got != auditKeepOld {
|
||||
t.Fatalf("want keeper %d old files, got %d", auditKeepOld, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRotationRecords(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
s := NewStats(10)
|
||||
s.LoadAudit(path)
|
||||
|
||||
oldRotate := auditRotateBytes
|
||||
auditRotateBytes = 64
|
||||
defer func() { auditRotateBytes = oldRotate }()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(Req{Key: "k", Model: "m", Source: "s", Status: 200, OK: true})
|
||||
}
|
||||
matches, _ := filepath.Glob(path + ".*.old")
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("Record must rotate too: got %d old files", len(matches))
|
||||
}
|
||||
}
|
||||
@ -306,6 +306,14 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
.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-htag { flex:0 0 auto; display:flex; gap:4px; align-items:center;
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10px; font-weight:700; cursor:help; }
|
||||
.scr-htag .ht-cool { padding:2px 6px; border-radius:9px; background:rgba(255,80,80,.28); color:#ffd9d9;
|
||||
border:1px solid rgba(255,120,120,.5); }
|
||||
.scr-htag .ht-fail { padding:2px 6px; border-radius:9px; background:rgba(255,160,60,.2); color:#ffd9a8;
|
||||
border:1px solid rgba(255,180,90,.42); }
|
||||
.scr-htag .ht-pref { padding:2px 6px; border-radius:9px; background:rgba(120,180,255,.18); color:#cfe3ff;
|
||||
border:1px solid rgba(150,190,255,.38); }
|
||||
.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; }
|
||||
@ -471,9 +479,11 @@ const STR = {
|
||||
seedWarnTitle:'请更换初始管理员密钥', seedWarnText:'当前登录的是配置文件中的初始密钥,明文写入 config.yaml、存在泄露风险。请在下方创建新的管理员密钥,用新密钥登录后删除此初始密钥。', seedWarnGo:'去更换密钥', seedWarnLater:'稍后', seedWarnDismiss:'本次不再提示',
|
||||
sortTitle:'拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||
sortCooling:'冷却', sortFail:'失败', sortHealthTip:'冷却 / 失败次数 / 偏好分 实时状态', sortHealthReset:'链上冷却已复位',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
|
||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量',
|
||||
dashStatus:'状态码分布', thCode:'状态码', statusTag:'状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)',
|
||||
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
||||
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
||||
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
|
||||
@ -525,9 +535,11 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
||||
seedWarnTitle:'Replace the initial admin key', seedWarnText:'You are logged in with the seed key from config.yaml. It is plaintext in the config file and a security risk. Create a new admin key below, log in with it, then delete this seed key.', seedWarnGo:'Change my key', seedWarnLater:'Later', seedWarnDismiss:'Don\'t ask again',
|
||||
sortTitle:'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 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',
|
||||
sortCooling:'cooling', sortFail:'fail', sortHealthTip:'live cooldown / failures / preference score', sortHealthReset:'chain cooldowns reset',
|
||||
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', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download',
|
||||
dashStatus:'Status codes', thCode:'Code', statusTag:'Per-status aggregates — 402 quota / 400 schema errors are counted here but never back off the provider',
|
||||
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
|
||||
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
|
||||
thTime:'Time', thType:'Type', thStatus:'Status', thLatMs:'Latency',
|
||||
@ -658,6 +670,7 @@ async function renderStatus() {
|
||||
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
|
||||
${s.sources ? `<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>` : ''}
|
||||
</div>
|
||||
<div class="card"><h2>${t('dashStatus')} <span class="muted" style="font-weight:400;font-size:12px">${t('statusTag')}</span></h2><div id="tb-status"></div></div>
|
||||
<div class="card"><h2>${t('dashKey')}<span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2><div id="tb-key"></div></div>
|
||||
<div class="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
|
||||
<div class="filter-line">
|
||||
@ -732,6 +745,7 @@ 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>`;
|
||||
paintModelTable(st.by_model || []);
|
||||
paintSrcTable(st.by_source || []);
|
||||
paintStatusTable(st.by_status || []);
|
||||
paintKeyTable(st.by_key || [], st.key_names || {});
|
||||
paintRecords(st.records || [], st.key_names || {});
|
||||
renderKeySelect((st.by_key || []).map(k => k.name));
|
||||
@ -762,6 +776,13 @@ function paintSrcTable(rows) {
|
||||
<td class="num">${fmtTok(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>';
|
||||
}
|
||||
function paintStatusTable(rows) {
|
||||
const el = $('#tb-status'); if (!el) return;
|
||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thCode')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th></tr>` +
|
||||
rows.map(r => `<tr><td><b class="${+r.name >= 400 ? 'errc' : 'okc'}">${esc(r.name)}</b></td>
|
||||
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td></tr>`).join('') + '</table></div>';
|
||||
}
|
||||
function paintKeyTable(rows, keyNames) {
|
||||
const el = $('#tb-key'); if (!el) return;
|
||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
@ -1139,10 +1160,15 @@ function srcColor(name) {
|
||||
}
|
||||
function srcShort(name) { return (name || '?').slice(0, 2).toUpperCase(); }
|
||||
const sortState = { lanes: [], origin: null, drag: null };
|
||||
let sortStateMap = new Map();
|
||||
async function renderSort() {
|
||||
const j = await api('/api/sources');
|
||||
let autoR = [];
|
||||
try { autoR = (await api('/api/auto')).rules || []; } catch (e) {}
|
||||
try {
|
||||
const a = await api('/api/auto');
|
||||
autoR = a.rules || [];
|
||||
sortStateMap = new Map((a.states || []).map(st => [st.model + '|' + (st.source || '*'), st]));
|
||||
} catch (e) {}
|
||||
const byModel = new Map();
|
||||
const byPair = new Map();
|
||||
const sourceRows = new Map();
|
||||
@ -1194,6 +1220,16 @@ async function renderSort() {
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
function healthTag(it) {
|
||||
const st = sortStateMap.get(it.id + '|' + (it.src || '*'));
|
||||
if (!st) return '';
|
||||
const bits = [];
|
||||
if (st.cooling) bits.push(`<span class="ht-cool">${esc(t('sortCooling'))}</span>`);
|
||||
if (st.fail_count > 0) bits.push(`<span class="ht-fail">${esc(t('sortFail') + '×' + st.fail_count)}</span>`);
|
||||
if (st.pref !== 0) bits.push(`<span class="ht-pref">${esc(st.pref > 0 ? '+' + st.pref : '' + st.pref)}</span>`);
|
||||
if (!bits.length) return '';
|
||||
return `<span class="scr-htag" title="${escAttr(t('sortHealthTip'))}">${bits.join('')}</span>`;
|
||||
}
|
||||
function scrBlockHtml(it, isFirst, li, ji, extraClass) {
|
||||
const c = srcColor(it.src);
|
||||
const s = srcShort(it.src);
|
||||
@ -1206,6 +1242,7 @@ function scrBlockHtml(it, isFirst, li, ji, extraClass) {
|
||||
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</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>` : ''}
|
||||
${healthTag(it)}
|
||||
<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>`;
|
||||
@ -1521,7 +1558,7 @@ async function saveSort() {
|
||||
try {
|
||||
await persistAuto();
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
toast(t('sortSaved'));
|
||||
toast(t('sortSaved') + ' · ' + t('sortHealthReset'));
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
@ -1986,10 +2023,20 @@ function showCtx(x, y, items) {
|
||||
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; } }
|
||||
// Close the ctx menu on any primary click/press OUTSIDE the menu. Both
|
||||
// listeners run in the CAPTURE phase, so they fire even when the clicked
|
||||
// element stops propagation (priority blocks and key bricks call
|
||||
// stopPropagation in their own click handlers, which would otherwise keep the
|
||||
// menu open forever). Presses INSIDE the menu are left alone: the menu's own
|
||||
// click handler closes it after running the item action.
|
||||
document.addEventListener('click', e => {
|
||||
if (e.button === 0 && !(ctxEl && ctxEl.contains(e.target))) hideCtx();
|
||||
}, true);
|
||||
document.addEventListener('mousedown', e => {
|
||||
if (e.button === 0 && !(ctxEl && ctxEl.contains(e.target))) hideCtx();
|
||||
}, true);
|
||||
/* cross-canvas brick dragging */
|
||||
function bindBrickDrag(b) {
|
||||
b.addEventListener('dragstart', e => {
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
@ -20,36 +21,109 @@ import (
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// health tracks availability with exponential backoff.
|
||||
type health struct {
|
||||
failCount int
|
||||
unavailableUntil time.Time
|
||||
permanent bool
|
||||
// ---- per-(source,model) scheduling state ----
|
||||
|
||||
// ModelState is the scheduling state of one (source, model) pair: a soft
|
||||
// preference score used to order candidates within a priority tier, a
|
||||
// consecutive-failure counter driving exponential cooldown, and a hard
|
||||
// cooldown deadline. All fields are atomic and cooldown expiry is evaluated
|
||||
// lazily (no timers, no goroutines). A model is never permanently blacklisted:
|
||||
// cooldown always expires and any success resets the state, so a fixed
|
||||
// upstream recovers on its own.
|
||||
type ModelState struct {
|
||||
pref atomic.Int64 // +1 per success / -5 per failure, clamped
|
||||
failCount atomic.Int64
|
||||
cooldownUntil atomic.Int64 // unix seconds; 0 = schedulable
|
||||
}
|
||||
|
||||
func (h *health) reset() { h.failCount = 0; h.unavailableUntil = time.Time{}; h.permanent = false }
|
||||
const (
|
||||
prefFailStep = 5
|
||||
prefMin = -20
|
||||
prefMax = 20
|
||||
backoffBase = 5 * time.Second
|
||||
backoffCap = 30 * time.Minute
|
||||
// failures at/above this count back off at the capped duration
|
||||
backoffCapN = 10
|
||||
)
|
||||
|
||||
func (h *health) available() bool {
|
||||
if h.permanent {
|
||||
return false
|
||||
func clampPref(a *atomic.Int64, lo, hi int64) {
|
||||
for {
|
||||
cur := a.Load()
|
||||
if cur < lo {
|
||||
if a.CompareAndSwap(cur, lo) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur > hi {
|
||||
if a.CompareAndSwap(cur, hi) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
return time.Now().After(h.unavailableUntil)
|
||||
}
|
||||
|
||||
func (h *health) backoff() {
|
||||
h.failCount++
|
||||
cooldown := 5 * time.Second * time.Duration(1<<(h.failCount-1))
|
||||
if cooldown > 30*time.Minute {
|
||||
cooldown = 30 * time.Minute
|
||||
// Available reports whether the model may be scheduled right now (cooldown
|
||||
// expired or not yet set).
|
||||
func (s *ModelState) Available() bool {
|
||||
return s.cooldownUntil.Load() <= time.Now().Unix()
|
||||
}
|
||||
|
||||
// RecordFailure counts one consecutive failure and schedules exponential
|
||||
// cooldown (5s, 10s, 20s … capped at 30min). auth marks 401/403 credential
|
||||
// failures: it jumps straight to the capped cooldown and doubles the
|
||||
// preference penalty, but the model still recovers when the cooldown expires.
|
||||
func (s *ModelState) RecordFailure(auth bool) {
|
||||
n := s.failCount.Add(1)
|
||||
if auth && n < backoffCapN {
|
||||
n = backoffCapN
|
||||
s.failCount.Store(n) // persist the cap so FailCount() reports it too
|
||||
}
|
||||
h.unavailableUntil = time.Now().Add(cooldown)
|
||||
var cd time.Duration
|
||||
if n >= backoffCapN {
|
||||
cd = backoffCap
|
||||
} else {
|
||||
cd = backoffBase * time.Duration(1<<(n-1))
|
||||
if cd > backoffCap {
|
||||
cd = backoffCap
|
||||
}
|
||||
}
|
||||
s.cooldownUntil.Store(time.Now().Add(cd).Unix())
|
||||
pen := int64(prefFailStep)
|
||||
if auth {
|
||||
pen *= 2
|
||||
}
|
||||
s.pref.Add(-pen)
|
||||
clampPref(&s.pref, prefMin, prefMax)
|
||||
}
|
||||
|
||||
func (h *health) markPermanent() {
|
||||
h.permanent = true
|
||||
h.unavailableUntil = time.Time{}
|
||||
// RecordSuccess resets the failure counter and cooldown and bumps the
|
||||
// preference score by one.
|
||||
func (s *ModelState) RecordSuccess() {
|
||||
s.failCount.Store(0)
|
||||
s.cooldownUntil.Store(0)
|
||||
s.pref.Add(1)
|
||||
clampPref(&s.pref, prefMin, prefMax)
|
||||
}
|
||||
|
||||
func (s *ModelState) reset() {
|
||||
s.failCount.Store(0)
|
||||
s.cooldownUntil.Store(0)
|
||||
s.pref.Store(0)
|
||||
}
|
||||
|
||||
// Pref is the current preference score (higher = preferred).
|
||||
func (s *ModelState) Pref() int64 { return s.pref.Load() }
|
||||
|
||||
// FailCount is the number of consecutive failures.
|
||||
func (s *ModelState) FailCount() int64 { return s.failCount.Load() }
|
||||
|
||||
// CooldownUntil is the unix timestamp until which the model is cooled; 0 when
|
||||
// schedulable.
|
||||
func (s *ModelState) CooldownUntil() int64 { return s.cooldownUntil.Load() }
|
||||
|
||||
// Provider is a single configured upstream LLM source.
|
||||
type Provider struct {
|
||||
cfg config.Source
|
||||
@ -59,11 +133,11 @@ type Provider struct {
|
||||
|
||||
mu sync.Mutex
|
||||
sem chan struct{}
|
||||
health health
|
||||
states map[string]*ModelState // key = model id
|
||||
lastProbe struct {
|
||||
ok bool
|
||||
err string
|
||||
at int64
|
||||
ok bool
|
||||
err string
|
||||
at int64
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,17 +148,21 @@ func New(cfg config.Source, vm *lua.VM) *Provider {
|
||||
adapter: cfg.Adapter,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
sem: make(chan struct{}, cfg.MaxConcurrent),
|
||||
states: map[string]*ModelState{},
|
||||
}
|
||||
if cfg.MaxConcurrent <= 0 {
|
||||
p.sem = nil
|
||||
}
|
||||
for _, m := range cfg.Models {
|
||||
p.states[m.ID] = &ModelState{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return p.cfg.Name }
|
||||
func (p *Provider) Adapter() string { return p.cfg.Adapter }
|
||||
func (p *Provider) MaxConcurrent() int { return p.cfg.MaxConcurrent }
|
||||
func (p *Provider) Config() *config.Source { return &p.cfg }
|
||||
func (p *Provider) Name() string { return p.cfg.Name }
|
||||
func (p *Provider) Adapter() string { return p.cfg.Adapter }
|
||||
func (p *Provider) MaxConcurrent() int { return p.cfg.MaxConcurrent }
|
||||
func (p *Provider) Config() *config.Source { return &p.cfg }
|
||||
|
||||
// Models returns the model ids exposed by this source.
|
||||
func (p *Provider) Models() []string {
|
||||
@ -174,10 +252,59 @@ func (p *Provider) ImageURL() string {
|
||||
|
||||
// ---- availability ----
|
||||
|
||||
// ErrBusy is returned when every concurrency slot of a source is in use. It
|
||||
// is a soft signal: schedulers skip a busy candidate without recording any
|
||||
// failure (busy is not a failure) and gateways map it to HTTP 429. It aliases
|
||||
// types.ErrBusy so the scheduler layer (which must not depend on this package)
|
||||
// can detect busy via the shared sentinel.
|
||||
var ErrBusy = types.ErrBusy
|
||||
|
||||
// Available reports whether the source is schedulable at source level: at
|
||||
// least one of its models is not cooling down. Per-model scheduling decisions
|
||||
// must use ModelAvailable instead.
|
||||
func (p *Provider) Available() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.health.available()
|
||||
for _, s := range p.states {
|
||||
if s.Available() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ModelAvailable reports whether the exact model is schedulable right now
|
||||
// (its cooldown expired). An unknown model id is treated as available.
|
||||
func (p *Provider) ModelAvailable(model string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if s, ok := p.states[model]; ok {
|
||||
return s.Available()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Pref returns the adaptive preference score of a model (higher = preferred
|
||||
// within a priority tier). Used by the AUTO chain to order same-tier slots.
|
||||
func (p *Provider) Pref(model string) int64 {
|
||||
return p.state(model).Pref()
|
||||
}
|
||||
|
||||
// ResetModelCooldown clears the cooldown and failure counter of a single
|
||||
// model while preserving its preference score. Called after AUTO-chain edits
|
||||
// so edited slots become schedulable immediately (a stored preference for a
|
||||
// reliably good model is kept).
|
||||
func (p *Provider) ResetModelCooldown(model string) {
|
||||
s := p.state(model)
|
||||
s.failCount.Store(0)
|
||||
s.cooldownUntil.Store(0)
|
||||
}
|
||||
|
||||
// ModelHealthInfo exposes the per-model scheduling state for the web UI.
|
||||
// An unknown model id reports zeros.
|
||||
func (p *Provider) ModelHealthInfo(model string) (pref, failCount, cooldownUntil int64) {
|
||||
s := p.state(model)
|
||||
return s.Pref(), s.FailCount(), s.CooldownUntil()
|
||||
}
|
||||
|
||||
// Probe performs a lightweight reachability + auth check against the source.
|
||||
@ -274,35 +401,106 @@ func (p *Provider) LastProbe() (bool, string, int64) {
|
||||
return p.lastProbe.ok, p.lastProbe.err, p.lastProbe.at
|
||||
}
|
||||
|
||||
// ReportStatus records an upstream HTTP status for backoff decisions.
|
||||
func (p *Provider) ReportStatus(code int) {
|
||||
// state returns the ModelState for a model id, creating it on first use so
|
||||
// dynamically requested models are still tracked. The registry keeps states
|
||||
// alive across provider rebuilds only for configured models; a lazily created
|
||||
// state simply lives for the provider's lifetime.
|
||||
func (p *Provider) state(model string) *ModelState {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s, ok := p.states[model]
|
||||
if !ok {
|
||||
s = &ModelState{}
|
||||
p.states[model] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RecordFailure records a failed downstream attempt on model: consecutive
|
||||
// failure count +1 and exponential cooldown (5s·2^n, capped at 30min).
|
||||
// code 401/403 is treated as a credential problem: the cooldown jumps to the
|
||||
// cap and the preference penalty doubles, but the model still recovers when
|
||||
// the cooldown expires (no permanent blacklist). code 0 = transport failure.
|
||||
func (p *Provider) RecordFailure(model string, code int) {
|
||||
p.state(model).RecordFailure(code == 401 || code == 403)
|
||||
}
|
||||
|
||||
// RecordSuccess resets the model's failure counter / cooldown and bumps its
|
||||
// preference by one.
|
||||
func (p *Provider) RecordSuccess(model string) {
|
||||
p.state(model).RecordSuccess()
|
||||
}
|
||||
|
||||
// ReportStatus records an upstream HTTP status for the given model and drives
|
||||
// the (source, model) backoff state. 401/403 → capped self-healing cooldown
|
||||
// with doubled penalty; 429 and 5xx → normal exponential backoff. Other codes
|
||||
// (400 client schema errors, 402 billing errors) are not penalized here —
|
||||
// they surface via the status page / audit instead.
|
||||
func (p *Provider) ReportStatus(model string, code int) {
|
||||
if code == 401 || code == 403 {
|
||||
p.health.markPermanent()
|
||||
p.RecordFailure(model, code)
|
||||
return
|
||||
}
|
||||
if code >= 500 || code == 429 {
|
||||
p.health.backoff()
|
||||
p.RecordFailure(model, code)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) reportError() {
|
||||
// ResetHealth resets the scheduling state of every model of this source
|
||||
// (cooldown and preference to zero), so the source becomes fully schedulable
|
||||
// again. Called after AUTO-chain edits and from the admin UI.
|
||||
func (p *Provider) ResetHealth() {
|
||||
p.mu.Lock()
|
||||
p.health.backoff()
|
||||
p.mu.Unlock()
|
||||
defer p.mu.Unlock()
|
||||
for _, s := range p.states {
|
||||
s.reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) reportOK() {
|
||||
// HealthInfo exposes the source-level backoff state for the status page: the
|
||||
// highest failure count and the latest cooldown deadline across all models of
|
||||
// this source. permanent is always false — the permanent-blacklist semantics
|
||||
// were removed; every cooldown expires on its own.
|
||||
func (p *Provider) HealthInfo() (failCount int, until time.Time, permanent bool) {
|
||||
p.mu.Lock()
|
||||
p.health.reset()
|
||||
p.mu.Unlock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now().Unix()
|
||||
for _, s := range p.states {
|
||||
if n := int(s.FailCount()); n > failCount {
|
||||
failCount = n
|
||||
}
|
||||
if t := s.CooldownUntil(); t > now && t > until.Unix() {
|
||||
until = time.Unix(t, 0)
|
||||
}
|
||||
}
|
||||
return failCount, until, false
|
||||
}
|
||||
|
||||
// ---- concurrency limiting ----
|
||||
|
||||
// TryAcquire takes one concurrency slot without blocking: it returns nil when
|
||||
// a slot is free and ErrBusy when the source is at capacity. A nil semaphore
|
||||
// (MaxConcurrent <= 0) means unlimited and always succeeds. TryAcquire is the
|
||||
// single busy/idle signal for schedulers; a busy source is skipped, never
|
||||
// penalized.
|
||||
func (p *Provider) TryAcquire(ctx context.Context) error {
|
||||
if p.sem == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case p.sem <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return ErrBusy
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire waits for a free concurrency slot (bounded by cfg.QueueTimeout),
|
||||
// or context cancel. The HTTP call itself is not truncated.
|
||||
// or context cancel. The HTTP call itself is not truncated. Direct requests
|
||||
// historically queued here; the scheduler now prefers TryAcquire so a full
|
||||
// source fails fast instead of blocking the whole chain.
|
||||
func (p *Provider) Acquire(ctx context.Context) error {
|
||||
if p.sem == nil {
|
||||
return nil
|
||||
@ -367,11 +565,14 @@ func (p *Provider) buildHeaders(body, url string) (http.Header, error) {
|
||||
// ---- chat ----
|
||||
|
||||
// Chat performs a non-streaming round trip and returns the unified response.
|
||||
// It fails fast with ErrBusy when the source is at capacity; success/failure
|
||||
// is recorded against the resolved (source, model) scheduling state.
|
||||
func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error) {
|
||||
if err := p.Acquire(ctx); err != nil {
|
||||
if err := p.TryAcquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.Release()
|
||||
model := p.ModelFor(req.Model)
|
||||
|
||||
body, err := marshalTransform(p.vm, p.adapter, "transform_request", req)
|
||||
if err != nil {
|
||||
@ -383,11 +584,11 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
}
|
||||
raw, status, err := p.do(ctx, p.URL(), body, hdrs)
|
||||
if err != nil {
|
||||
p.reportError()
|
||||
p.RecordFailure(model, 0)
|
||||
return nil, err
|
||||
}
|
||||
if status != 200 {
|
||||
p.ReportStatus(status)
|
||||
p.ReportStatus(model, status)
|
||||
return nil, fmt.Errorf("api error %d: %s", status, truncate(raw, 500))
|
||||
}
|
||||
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
|
||||
@ -398,15 +599,21 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
if err := json.Unmarshal([]byte(unified), &out); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unified)
|
||||
}
|
||||
p.reportOK()
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ChatStream performs a streaming round trip, emitting unified chunks.
|
||||
// ChatStream performs a streaming round trip, emitting unified chunks. It
|
||||
// fails fast with ErrBusy when the source is at capacity. Only a failure
|
||||
// before the first chunk (connect error or non-200 status) is recorded
|
||||
// against the (source, model) state; afterwards the stream is pinned. A clean
|
||||
// end ([DONE] or EOF without read errors, and no client disconnect) counts as
|
||||
// success and resets the cooldown.
|
||||
func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error) {
|
||||
if err := p.Acquire(ctx); err != nil {
|
||||
if err := p.TryAcquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model := p.ModelFor(req.Model)
|
||||
req.Stream = true
|
||||
body, err := marshalTransform(p.vm, p.adapter, "transform_request", req)
|
||||
if err != nil {
|
||||
@ -432,14 +639,14 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
ch := make(chan types.UnifiedChunk, 64)
|
||||
sel := <-rc
|
||||
if sel.err != nil {
|
||||
p.reportError()
|
||||
p.RecordFailure(model, 0)
|
||||
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.ReportStatus(model, sel.resp.StatusCode)
|
||||
p.Release()
|
||||
return nil, fmt.Errorf("api error %d: %s", sel.resp.StatusCode, truncate(string(raw), 500))
|
||||
}
|
||||
@ -485,16 +692,25 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
return
|
||||
}
|
||||
}
|
||||
// The stream ended cleanly ([DONE] seen or EOF without an upstream
|
||||
// read error): record success so a previously cooled model can be
|
||||
// retried. A client disconnect or mid-stream read error is neither
|
||||
// success nor failure for scheduling purposes.
|
||||
if ctx.Err() == nil && scanner.Err() == nil {
|
||||
p.RecordSuccess(model)
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Image generates images via /v1/images/generations.
|
||||
// Image generates images via /v1/images/generations. Same scheduling-state
|
||||
// accounting as Chat: fail fast on busy, record per (source, model).
|
||||
func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error) {
|
||||
if err := p.Acquire(ctx); err != nil {
|
||||
if err := p.TryAcquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.Release()
|
||||
model := p.ModelFor(req.Model)
|
||||
|
||||
b, _ := json.Marshal(req)
|
||||
transformed, err := p.vm.Transform(p.adapter+"_image", "transform_request", string(b))
|
||||
@ -508,11 +724,11 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
}
|
||||
raw, status, err := p.do(ctx, p.ImageURL(), transformed, hdrs)
|
||||
if err != nil {
|
||||
p.reportError()
|
||||
p.RecordFailure(model, 0)
|
||||
return nil, err
|
||||
}
|
||||
if status != 200 {
|
||||
p.ReportStatus(status)
|
||||
p.ReportStatus(model, status)
|
||||
return nil, fmt.Errorf("image api error %d: %s", status, truncate(raw, 500))
|
||||
}
|
||||
var out types.UnifiedResponse
|
||||
@ -520,7 +736,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
unified, terr := p.vm.Transform(p.adapter+"_image", "transform_response", raw)
|
||||
if terr == nil && unified != raw {
|
||||
if err := json.Unmarshal([]byte(unified), &out); err == nil {
|
||||
p.reportOK()
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
}
|
||||
@ -529,7 +745,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
return nil, fmt.Errorf("unmarshal image response: %w", err)
|
||||
}
|
||||
out.ImageData = img.Data
|
||||
p.reportOK()
|
||||
p.RecordSuccess(model)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
@ -593,4 +809,4 @@ func truncate(s string, n int) string {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,12 @@ package provider
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -90,6 +91,49 @@ func TestProviderChatStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamSuccessClearsBackoff guards P6: a clean streaming end must reset
|
||||
// a previously cooled (source, model) pair.
|
||||
func TestStreamSuccessClearsBackoff(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
fail.Store(true)
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail.Load() {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n")
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer up.Close()
|
||||
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
|
||||
if _, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
}); err == nil {
|
||||
t.Fatal("expected first chat to fail")
|
||||
}
|
||||
if p.ModelAvailable("m") {
|
||||
t.Fatal("m must be cooling after the failed chat")
|
||||
}
|
||||
fail.Store(false)
|
||||
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
|
||||
Model: "m",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
if !p.ModelAvailable("m") {
|
||||
t.Fatal("clean stream must clear the cooldown")
|
||||
}
|
||||
if st := p.state("m"); st.FailCount() != 0 {
|
||||
t.Fatalf("fail count after clean stream = %d", st.FailCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderImage(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"created":123,"data":[{"b64_json":"QUJD"}]}`)
|
||||
@ -105,12 +149,71 @@ func TestProviderImage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderBackoff(t *testing.T) {
|
||||
func TestProviderBackoffPerModel(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, "boom")
|
||||
}))
|
||||
defer up.Close()
|
||||
// two models on one source: a failure on m1 must not blacklist m2
|
||||
p := newTestProvider(t, src("mock", up.URL, "openai", "m1", "m2"))
|
||||
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if p.ModelAvailable("m1") {
|
||||
t.Fatal("expected m1 to be cooling down")
|
||||
}
|
||||
if !p.ModelAvailable("m2") {
|
||||
t.Fatal("m2 must stay schedulable (per-model isolation)")
|
||||
}
|
||||
if !p.Available() {
|
||||
t.Fatal("source must stay available while any model is schedulable")
|
||||
}
|
||||
if st := p.state("m1"); st.FailCount() != 1 {
|
||||
t.Fatalf("fail count = %d, want 1", st.FailCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderAuthFailureSelfHeals(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, `{"error":"API_KEY_DISABLED"}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
p := newTestProvider(t, src("mock2", up.URL, "openai", "m2"))
|
||||
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m2",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
st := p.state("m2")
|
||||
if st.FailCount() != backoffCapN {
|
||||
t.Fatalf("auth failure must jump to capped count, got %d", st.FailCount())
|
||||
}
|
||||
if until := st.CooldownUntil(); until <= time.Now().Add(25*time.Minute).Unix() {
|
||||
t.Fatalf("auth failure must cool near the cap (until=%d)", until)
|
||||
}
|
||||
if st.Pref() != -2*int64(prefFailStep) {
|
||||
t.Fatalf("auth failure pref penalty must be doubled, got %d", st.Pref())
|
||||
}
|
||||
// not permanent: the reset channel and a later success both restore it
|
||||
st.reset()
|
||||
if !p.ModelAvailable("m2") {
|
||||
t.Fatal("reset must restore schedulability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelStateCooldownAndRecovery(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer up.Close()
|
||||
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
|
||||
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m",
|
||||
@ -119,55 +222,79 @@ func TestProviderBackoff(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if p.Available() {
|
||||
t.Fatal("expected provider to be in backoff")
|
||||
st := p.state("m")
|
||||
if st.FailCount() != 1 {
|
||||
t.Fatalf("fail count = %d", st.FailCount())
|
||||
}
|
||||
// 401 -> permanent
|
||||
up2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
}))
|
||||
defer up2.Close()
|
||||
p2 := newTestProvider(t, src("mock2", up2.URL, "openai", "m2"))
|
||||
p2.Chat(context.Background(), &types.ChatRequest{Model: "m2", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}})
|
||||
if p2.Available() {
|
||||
t.Fatal("expected permanent unavailability on 401")
|
||||
// one failure -> 5s cooldown from now
|
||||
until := st.CooldownUntil()
|
||||
want := time.Now().Add(backoffBase).Unix()
|
||||
if until < want-2 || until > want+2 {
|
||||
t.Fatalf("cooldown = %d, want ~%d", until, want)
|
||||
}
|
||||
// success resets everything and bumps the preference
|
||||
st.RecordSuccess()
|
||||
if !p.ModelAvailable("m") {
|
||||
t.Fatal("success must clear cooldown")
|
||||
}
|
||||
if st.FailCount() != 0 {
|
||||
t.Fatalf("fail count after success = %d", st.FailCount())
|
||||
}
|
||||
if st.Pref() != 1-int64(prefFailStep) {
|
||||
t.Fatalf("pref after one failure (-5) then success (+1) = %d, want %d", st.Pref(), 1-int64(prefFailStep))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderConcurrencyCap(t *testing.T) {
|
||||
func TestTryAcquire(t *testing.T) {
|
||||
p := newTestProvider(t, src("mock", "http://127.0.0.1:1", "openai", "m"))
|
||||
p.cfg.MaxConcurrent = 1
|
||||
p.sem = make(chan struct{}, 1)
|
||||
if err := p.TryAcquire(context.Background()); err != nil {
|
||||
t.Fatalf("first acquire: %v", err)
|
||||
}
|
||||
if err := p.TryAcquire(context.Background()); !errors.Is(err, ErrBusy) {
|
||||
t.Fatalf("second acquire = %v, want ErrBusy", err)
|
||||
}
|
||||
p.Release()
|
||||
if err := p.TryAcquire(context.Background()); err != nil {
|
||||
t.Fatalf("acquire after release: %v", err)
|
||||
}
|
||||
p.Release()
|
||||
}
|
||||
|
||||
func TestChatBusyFailsFast(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{}, 100)
|
||||
started := make(chan struct{}, 10)
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
// cap 2
|
||||
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
|
||||
p.cfg.MaxConcurrent = 2
|
||||
p.sem = make(chan struct{}, 2)
|
||||
p.cfg.MaxConcurrent = 1
|
||||
p.sem = make(chan struct{}, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.Chat(context.Background(), &types.ChatRequest{Model: "m", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}})
|
||||
}()
|
||||
}
|
||||
// wait until 2 requests started
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for len(started) < 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("timeout waiting for first two")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if len(started) > 2 {
|
||||
t.Fatalf("more than 2 concurrent: %d", len(started))
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
<-started // first request holds the only slot
|
||||
|
||||
// second request must fail fast with ErrBusy instead of queueing
|
||||
_, err2 := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
|
||||
})
|
||||
if !errors.Is(err2, ErrBusy) {
|
||||
t.Fatalf("second chat err = %v, want ErrBusy", err2)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("first chat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,63 +75,39 @@ func (r *Registry) ModelList() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolve returns the ordered candidate providers to try for a request,
|
||||
// honoring explicit model selection or AUTO (priority order, healthy first).
|
||||
// Resolve returns the provider (or providers) serving a requested model,
|
||||
// owning no AUTO scheduling logic anymore: AUTO chat scheduling is driven by
|
||||
// the scheduler chain built from the runtime rules (see core/SaveAutoRules
|
||||
// and scheduler.Chain).
|
||||
//
|
||||
// model "" or "AUTO" -> all sources sorted by (priority desc, healthy first).
|
||||
// Otherwise the owning provider, if healthy; else its source anyway.
|
||||
// model "" or "AUTO" -> every provider in configured order. Used only by the
|
||||
// image path (which then filters to image-capable sources) and tool-call
|
||||
// anchoring; chat AUTO requests go through the chain instead.
|
||||
// Otherwise the owning provider; "source-model"/"source:model"/"source/model"
|
||||
// pinning resolves first; an unknown model resolves to nil (gateway answers
|
||||
// 404) instead of silently falling back to the AUTO chain.
|
||||
func (r *Registry) Resolve(model string) []*Provider {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" || strings.EqualFold(model, "AUTO") {
|
||||
// priority chain across all models
|
||||
type cand struct {
|
||||
prov *Provider
|
||||
priority int
|
||||
}
|
||||
var cands []cand
|
||||
seen := map[string]bool{}
|
||||
for _, p := range r.providers {
|
||||
prio := -1
|
||||
for _, m := range p.cfg.Models {
|
||||
if m.Priority > prio {
|
||||
prio = m.Priority
|
||||
}
|
||||
}
|
||||
if prio < 0 {
|
||||
prio = 0
|
||||
}
|
||||
cands = append(cands, cand{p, prio})
|
||||
seen[p.Name()] = true
|
||||
}
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
if cands[i].priority != cands[j].priority {
|
||||
return cands[i].priority > cands[j].priority
|
||||
}
|
||||
// healthy preferred at same priority
|
||||
return cands[i].prov.Available() && !cands[j].prov.Available()
|
||||
})
|
||||
out := make([]*Provider, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
out = append(out, c.prov)
|
||||
}
|
||||
out := make([]*Provider, len(r.providers))
|
||||
copy(out, r.providers)
|
||||
return out
|
||||
}
|
||||
|
||||
// explicit model
|
||||
if p, ok := r.byModel[strings.ToLower(model)]; ok {
|
||||
// switch to the owning source but pin the model via request
|
||||
return []*Provider{p}
|
||||
}
|
||||
// "source-model" / "source:model" / "source/model" pinning — disambiguates
|
||||
// duplicate model ids across sources.
|
||||
if p := r.ResolvePinned(model); p != nil {
|
||||
return []*Provider{p}
|
||||
}
|
||||
// unknown model -> fall back to default/AUTO chain
|
||||
return r.AUTOChain()
|
||||
// explicit model
|
||||
if p, ok := r.byModel[strings.ToLower(model)]; ok {
|
||||
// switch to the owning source but pin the model via request
|
||||
return []*Provider{p}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveModel strips a "source-model" / "source:model" / "source/model"
|
||||
@ -174,11 +150,6 @@ func (r *Registry) ResolvePinned(model string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AUTOChain returns the priority-sorted providers for AUTO.
|
||||
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()
|
||||
@ -220,15 +191,6 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default returns the highest-priority available provider.
|
||||
func (r *Registry) Default() *Provider {
|
||||
chain := r.AUTOChain()
|
||||
if len(chain) == 0 {
|
||||
return nil
|
||||
}
|
||||
return chain[0]
|
||||
}
|
||||
|
||||
// ModelStatus is a web-UI friendly snapshot per source.
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
@ -241,6 +203,9 @@ type SourceStatus struct {
|
||||
LiveAvailable bool `json:"live_available"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastChecked int64 `json:"last_checked,omitempty"`
|
||||
FailCount int `json:"fail_count,omitempty"`
|
||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||
Permanent bool `json:"permanent,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeAll runs a live reachability check for every provider (in parallel).
|
||||
@ -267,19 +232,27 @@ func (r *Registry) Status() []SourceStatus {
|
||||
out := make([]SourceStatus, 0, len(r.providers))
|
||||
for _, p := range r.providers {
|
||||
live, lastErr, lastAt := p.LastProbe()
|
||||
s := SourceStatus{
|
||||
Name: p.Name(),
|
||||
Adapter: p.Adapter(),
|
||||
BaseURL: p.Config().BaseURL,
|
||||
Models: p.Models(),
|
||||
Available: p.Available(),
|
||||
Healthy: p.Available(),
|
||||
MaxConcurrent: p.MaxConcurrent(),
|
||||
LiveAvailable: live,
|
||||
LastError: lastErr,
|
||||
LastChecked: lastAt,
|
||||
}
|
||||
out = append(out, s)
|
||||
fails, until, perm := p.HealthInfo()
|
||||
backoffUntil := int64(0)
|
||||
if !until.IsZero() {
|
||||
backoffUntil = until.Unix()
|
||||
}
|
||||
s := SourceStatus{
|
||||
Name: p.Name(),
|
||||
Adapter: p.Adapter(),
|
||||
BaseURL: p.Config().BaseURL,
|
||||
Models: p.Models(),
|
||||
Available: p.Available(),
|
||||
Healthy: p.Available(),
|
||||
MaxConcurrent: p.MaxConcurrent(),
|
||||
LiveAvailable: live,
|
||||
LastError: lastErr,
|
||||
LastChecked: lastAt,
|
||||
FailCount: fails,
|
||||
BackoffUntil: backoffUntil,
|
||||
Permanent: perm,
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -1,16 +1,29 @@
|
||||
// Package scheduler implements request scheduling across providers: per-source
|
||||
// concurrency caps (acquire with wait = queuing), AUTO model fallback chains,
|
||||
// and exponential backoff via provider health.
|
||||
// Package scheduler implements request scheduling across providers: direct
|
||||
// fallback scheduling over candidate lists, and the AUTO chain (tiers with
|
||||
// per-tier round-robin cursors, preference ordering, token-quota windows and
|
||||
// per-(source,model) cooldown awareness) per the target architecture in
|
||||
// plan.md.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// busyWait is how long a fully-busy tier is polled for a free slot before the
|
||||
// request falls through to the next tier (bounded wait, plan 2.3).
|
||||
var busyWait = 2 * time.Second
|
||||
|
||||
// busyPoll is the polling interval while waiting for a busy tier.
|
||||
var busyPoll = 100 * time.Millisecond
|
||||
|
||||
// Scheduler drives one chat tool call across the candidate provider chain.
|
||||
type Scheduler struct {
|
||||
// MaxRetries how many fallback providers to try before failing.
|
||||
@ -27,29 +40,297 @@ func New(maxRetries int) *Scheduler {
|
||||
// Provider is the minimal interface the scheduler needs to schedule over.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Available() bool
|
||||
ModelFor(reqModel string) string
|
||||
ModelAvailable(model string) bool
|
||||
Pref(model string) int64
|
||||
Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error)
|
||||
ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error)
|
||||
Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error)
|
||||
}
|
||||
|
||||
// FromRegistry converts *provider.Provider slices to the scheduler interface.
|
||||
func FromRegistry(ps []*provider.Provider) []Provider {
|
||||
out := make([]Provider, len(ps))
|
||||
for i, p := range ps {
|
||||
out[i] = p
|
||||
}
|
||||
return out
|
||||
// ---- AUTO chain ----
|
||||
|
||||
// Rule is one persisted AUTO chain slot (mirror of config.ModelScope).
|
||||
type Rule struct {
|
||||
Model string
|
||||
Source string
|
||||
Tier int
|
||||
Quota int64
|
||||
Period string
|
||||
Hours int64
|
||||
}
|
||||
|
||||
// Slot is one schedulable chain position: a model pinned to its provider,
|
||||
// with an optional token-quota window. Slots are immutable after build.
|
||||
type Slot struct {
|
||||
Model string
|
||||
Source string
|
||||
Quota int64
|
||||
Period string
|
||||
Hours int64
|
||||
Prov Provider
|
||||
}
|
||||
|
||||
// TierNode is one priority tier. Slots keep their configured order (the
|
||||
// stable base for preference ordering). next is the round-robin cursor: it
|
||||
// holds the last used slot index (-1 = none yet), so the very first request
|
||||
// starts at the configured order and later ones rotate.
|
||||
type TierNode struct {
|
||||
Tier int
|
||||
Slots []*Slot
|
||||
next atomic.Int64
|
||||
}
|
||||
|
||||
// NextStart advances the tier cursor and returns the start index for the next
|
||||
// scheduling run (first run: index 0).
|
||||
func (tn *TierNode) NextStart() int64 {
|
||||
return tn.next.Add(1)
|
||||
}
|
||||
|
||||
// Chain is the immutable AUTO scheduling plan. A rebuilt chain is swapped in
|
||||
// atomically; per-tier cursors live inside the chain and are shared across
|
||||
// requests (rotation state resets when the chain is rebuilt, e.g. after
|
||||
// editing the rules — acceptable, the swap also resets cooldowns).
|
||||
type Chain struct {
|
||||
Tiers []*TierNode // descending tier order
|
||||
}
|
||||
|
||||
// TierErrors is the per-tier failure summary carried by ChainErr. Errors
|
||||
// (TierError or skipped-tier reasons) are collected in tier order.
|
||||
type TierError struct {
|
||||
Tier int
|
||||
Source string
|
||||
Model string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ChainErr is returned by chain scheduling when every AUTO tier failed. Its
|
||||
// message summarizes each failed tier (which source/model and why) so a 503
|
||||
// names the culprits instead of the bare "no provider available".
|
||||
type ChainErr struct {
|
||||
Tiers []TierError
|
||||
Skipped []string // whole-tier reasons (cooling / quota / all busy)
|
||||
}
|
||||
|
||||
func (e *ChainErr) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("all auto tiers failed: ")
|
||||
first := true
|
||||
for _, t := range e.Tiers {
|
||||
if !first {
|
||||
b.WriteString("; ")
|
||||
}
|
||||
first = false
|
||||
fmt.Fprintf(&b, "tier %d %s/%s: %v", t.Tier, t.Source, t.Model, t.Err)
|
||||
}
|
||||
for _, s := range e.Skipped {
|
||||
if !first {
|
||||
b.WriteString("; ")
|
||||
}
|
||||
first = false
|
||||
b.WriteString(s)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildChain groups rules into descending tiers and resolves each slot's
|
||||
// provider via prov. Rules whose provider resolves to nil are dropped (the
|
||||
// source no longer serves the model). Slot order within a tier follows the
|
||||
// configured rule order.
|
||||
func BuildChain(rules []Rule, prov func(model, source string) Provider) *Chain {
|
||||
byTier := map[int][]*Slot{}
|
||||
var tiers []int
|
||||
for _, r := range rules {
|
||||
p := prov(r.Model, r.Source)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := byTier[r.Tier]; !ok {
|
||||
tiers = append(tiers, r.Tier)
|
||||
}
|
||||
byTier[r.Tier] = append(byTier[r.Tier], &Slot{
|
||||
Model: r.Model,
|
||||
Source: r.Source,
|
||||
Quota: r.Quota,
|
||||
Period: r.Period,
|
||||
Hours: r.Hours,
|
||||
Prov: p,
|
||||
})
|
||||
}
|
||||
sort.Slice(tiers, func(i, j int) bool { return tiers[i] > tiers[j] })
|
||||
ch := &Chain{}
|
||||
for _, t := range tiers {
|
||||
tn := &TierNode{Tier: t, Slots: byTier[t]}
|
||||
tn.next.Store(-1)
|
||||
ch.Tiers = append(ch.Tiers, tn)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// tierResult is the outcome of one scheduling run over one tier.
|
||||
type tierResult struct {
|
||||
resp *types.UnifiedResponse
|
||||
chunks <-chan types.UnifiedChunk
|
||||
src string
|
||||
model string
|
||||
hard []TierError // hard failures seen in this pass (nil = none)
|
||||
}
|
||||
|
||||
// runTier executes one tier pass starting at the round-robin base index.
|
||||
// Cooldown is the only hard skip (re-verified per slot); a busy slot is
|
||||
// skipped without any penalty; a hard failure is recorded and the pass moves
|
||||
// on to the next slot (plan 2.3: "单请求内不重试已失败槽" — the failed slot is
|
||||
// not retried, the others still are). hard == nil and no success means every
|
||||
// candidate was merely busy/cooling, so the caller may wait a bounded time.
|
||||
func runTier(ctx context.Context, tn *TierNode, cands []*Slot, base int64, req *types.ChatRequest, stream bool) tierResult {
|
||||
n := len(cands)
|
||||
var hard []TierError
|
||||
for i := 0; i < n; i++ {
|
||||
sl := cands[(int(base)+i)%n]
|
||||
if !sl.Prov.ModelAvailable(sl.Model) {
|
||||
continue
|
||||
}
|
||||
r := *req
|
||||
r.Model = sl.Model
|
||||
if stream {
|
||||
chunks, err := sl.Prov.ChatStream(ctx, &r)
|
||||
if err == nil {
|
||||
return tierResult{chunks: chunks, src: sl.Source, model: sl.Model}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return tierResult{}
|
||||
}
|
||||
if errors.Is(err, types.ErrBusy) {
|
||||
continue
|
||||
}
|
||||
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
|
||||
continue
|
||||
}
|
||||
resp, err := sl.Prov.Chat(ctx, &r)
|
||||
if err == nil {
|
||||
return tierResult{resp: resp, src: sl.Source, model: sl.Model}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return tierResult{}
|
||||
}
|
||||
if errors.Is(err, types.ErrBusy) {
|
||||
continue
|
||||
}
|
||||
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
|
||||
}
|
||||
return tierResult{hard: hard}
|
||||
}
|
||||
|
||||
// chainDrive runs a request down the chain (plan 2.3): tiers descending,
|
||||
// per-tier round-robin starting at the tier cursor, same-tier runs ordered by
|
||||
// preference (negative prefs sink but stay reachable). Quota-exhausted and
|
||||
// cooling slots are filtered up front; a fully busy tier is polled for a
|
||||
// bounded time before falling through. Failures are summarized in *ChainErr
|
||||
// for the caller to map to HTTP 503.
|
||||
func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, stream bool) (*types.UnifiedResponse, <-chan types.UnifiedChunk, string, string, error) {
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
||||
}
|
||||
var ce ChainErr
|
||||
for _, tn := range chain.Tiers {
|
||||
// initial filter: quota-exhausted and cooling slots are dropped
|
||||
var cands []*Slot
|
||||
for _, sl := range tn.Slots {
|
||||
if exhausted != nil && exhausted(sl) {
|
||||
continue
|
||||
}
|
||||
if !sl.Prov.ModelAvailable(sl.Model) {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, sl)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling or quota exhausted)", tn.Tier))
|
||||
continue
|
||||
}
|
||||
// preference orders a same-tier run; stable so equal prefs keep order
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
return cands[i].Prov.Pref(cands[i].Model) > cands[j].Prov.Pref(cands[j].Model)
|
||||
})
|
||||
base := tn.NextStart()
|
||||
res := runTier(ctx, tn, cands, base, req, stream)
|
||||
if res.resp != nil || res.chunks != nil {
|
||||
return res.resp, res.chunks, res.src, res.model, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, nil, "", "", ctx.Err()
|
||||
}
|
||||
if len(res.hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, res.hard...)
|
||||
continue // hard failures: fall through to the next tier, no waiting
|
||||
}
|
||||
// every candidate was busy or cooling: bounded poll before downgrading
|
||||
deadline := time.Now().Add(busyWait)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, "", "", ctx.Err()
|
||||
case <-time.After(busyPoll):
|
||||
}
|
||||
done := time.Now().After(deadline)
|
||||
if done {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
||||
break
|
||||
}
|
||||
// refresh candidates: cooldowns may have expired meanwhile
|
||||
var again []*Slot
|
||||
for _, sl := range cands {
|
||||
if sl.Prov.ModelAvailable(sl.Model) {
|
||||
again = append(again, sl)
|
||||
}
|
||||
}
|
||||
if len(again) == 0 {
|
||||
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
||||
break
|
||||
}
|
||||
res = runTier(ctx, tn, again, base, req, stream)
|
||||
if res.resp != nil || res.chunks != nil {
|
||||
return res.resp, res.chunks, res.src, res.model, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, nil, "", "", ctx.Err()
|
||||
}
|
||||
if len(res.hard) > 0 {
|
||||
ce.Tiers = append(ce.Tiers, res.hard...)
|
||||
break // hard failure while waiting: stop waiting, fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
|
||||
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
||||
}
|
||||
return nil, nil, "", "", &ce
|
||||
}
|
||||
|
||||
// ChainChat runs a non-streaming AUTO request down the chain. exhausted, when
|
||||
// non-nil, decides slot token-quota exhaustion. Returns the response, the
|
||||
// serving source and the exact model id used; on total failure a *ChainErr
|
||||
// summarizing every tier.
|
||||
func (s *Scheduler) ChainChat(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (*types.UnifiedResponse, string, string, error) {
|
||||
resp, _, src, model, err := s.chainDrive(ctx, chain, req, exhausted, false)
|
||||
return resp, src, model, err
|
||||
}
|
||||
|
||||
// ChainChatStream runs a streaming AUTO request down the chain. A slot is
|
||||
// abandoned only on connect failures / busy (before its first chunk); after a
|
||||
// stream starts it is pinned. Same return contract as ChainChat.
|
||||
func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (<-chan types.UnifiedChunk, string, string, error) {
|
||||
_, chunks, src, model, err := s.chainDrive(ctx, chain, req, exhausted, true)
|
||||
return chunks, src, model, err
|
||||
}
|
||||
|
||||
// ---- direct scheduling ----
|
||||
|
||||
// Chat runs a chat request across cands, falling back on failure. Each
|
||||
// candidate receives a request pinned to its own model (ModelFor), so an AUTO
|
||||
// chain fallback switches the model id per provider instead of reusing the
|
||||
// first candidate's model name.
|
||||
//
|
||||
// On success it returns the response together with the name of the provider
|
||||
// and the exact model id that actually served the request (used for stats).
|
||||
// candidate receives a request pinned to its own model (ModelFor), so a
|
||||
// fallback switches the model id per provider instead of reusing the first
|
||||
// candidate's model name. On success it returns the response together with
|
||||
// the name of the provider and the exact model id that served the request.
|
||||
func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatRequest) (*types.UnifiedResponse, string, string, error) {
|
||||
attempts := s.MaxRetries + 1
|
||||
var lastErr error
|
||||
@ -74,9 +355,10 @@ func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatR
|
||||
return nil, "", "", lastErr
|
||||
}
|
||||
|
||||
// ChatStream runs a streaming chat across cands, falling back early on connect
|
||||
// errors. The request model is pinned per candidate like Chat. On success it
|
||||
// returns the chunk channel plus the serving provider name and model id.
|
||||
// ChatStream runs a streaming chat across cands, falling back early on
|
||||
// connect errors. The request model is pinned per candidate like Chat. On
|
||||
// success it returns the chunk channel plus the serving provider name and
|
||||
// model id.
|
||||
func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types.ChatRequest) (<-chan types.UnifiedChunk, string, string, error) {
|
||||
attempts := s.MaxRetries + 1
|
||||
var lastErr error
|
||||
@ -113,4 +395,4 @@ func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.Imag
|
||||
lastErr = fmt.Errorf("no provider available")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
}
|
||||
|
||||
283
internal/scheduler/scheduler_test.go
Normal file
283
internal/scheduler/scheduler_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// fakeProvider is an in-memory Provider used to exercise chain scheduling
|
||||
// deterministically without a Lua runtime.
|
||||
type fakeProvider struct {
|
||||
name string
|
||||
model string
|
||||
pref atomic.Int64
|
||||
available atomic.Bool
|
||||
busy atomic.Bool
|
||||
fail atomic.Bool
|
||||
chatHits atomic.Int64
|
||||
}
|
||||
|
||||
func fakeProv(name, model string) *fakeProvider {
|
||||
f := &fakeProvider{name: name, model: model}
|
||||
f.available.Store(true)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Name() string { return f.name }
|
||||
|
||||
func (f *fakeProvider) ModelFor(reqModel string) string { return f.model }
|
||||
|
||||
func (f *fakeProvider) ModelAvailable(model string) bool { return f.available.Load() }
|
||||
|
||||
func (f *fakeProvider) Pref(model string) int64 { return f.pref.Load() }
|
||||
|
||||
func (f *fakeProvider) Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error) {
|
||||
f.chatHits.Add(1)
|
||||
if f.busy.Load() {
|
||||
return nil, types.ErrBusy
|
||||
}
|
||||
if f.fail.Load() {
|
||||
return nil, fmt.Errorf("upstream error")
|
||||
}
|
||||
return &types.UnifiedResponse{Content: f.name, FinishReason: "stop", TokenUsage: types.TokenUsage{Prompt: 1, Completion: 1, Total: 2}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeProvider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error) {
|
||||
if f.busy.Load() {
|
||||
return nil, types.ErrBusy
|
||||
}
|
||||
if f.fail.Load() {
|
||||
return nil, fmt.Errorf("upstream error")
|
||||
}
|
||||
ch := make(chan types.UnifiedChunk, 2)
|
||||
ch <- types.UnifiedChunk{Content: f.name}
|
||||
ch <- types.UnifiedChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error) {
|
||||
return nil, errors.New("no image")
|
||||
}
|
||||
|
||||
// lookup resolves (model, source) -> provider for chain builders in tests.
|
||||
type lookup func(m, s string) Provider
|
||||
|
||||
func bySource(ps ...*fakeProvider) lookup {
|
||||
return func(m, s string) Provider {
|
||||
for _, p := range ps {
|
||||
if p.name == s {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func chatReq() *types.ChatRequest {
|
||||
return &types.ChatRequest{Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}}
|
||||
}
|
||||
|
||||
func TestBuildChainTierOrdering(t *testing.T) {
|
||||
a, b, c, d := fakeProv("s1", "a"), fakeProv("s2", "b"), fakeProv("s3", "c"), fakeProv("s4", "d")
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 1, Model: "a", Source: "s1"},
|
||||
{Tier: 3, Model: "c", Source: "s3"},
|
||||
{Tier: 2, Model: "b", Source: "s2"},
|
||||
{Tier: 1, Model: "d", Source: "s4"},
|
||||
{Tier: 9, Model: "gone", Source: "missing"},
|
||||
}, bySource(a, b, c, d))
|
||||
if len(ch.Tiers) != 3 {
|
||||
t.Fatalf("tiers = %d, want 3", len(ch.Tiers))
|
||||
}
|
||||
if ch.Tiers[0].Tier != 3 || ch.Tiers[1].Tier != 2 || ch.Tiers[2].Tier != 1 {
|
||||
t.Fatalf("tier order = %d,%d,%d, want 3,2,1", ch.Tiers[0].Tier, ch.Tiers[1].Tier, ch.Tiers[2].Tier)
|
||||
}
|
||||
// same-tier slots keep configured order, unresolvable rule is dropped
|
||||
if len(ch.Tiers[2].Slots) != 2 || ch.Tiers[2].Slots[0].Model != "a" || ch.Tiers[2].Slots[1].Model != "d" {
|
||||
t.Fatalf("tier 1 slots = %+v", ch.Tiers[2].Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainRoundRobin(t *testing.T) {
|
||||
a, b := fakeProv("s1", "a"), fakeProv("s2", "b")
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "a", Source: "s1"},
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
var got []string
|
||||
for i := 0; i < 4; i++ {
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
got = append(got, src)
|
||||
}
|
||||
want := []string{"s1", "s2", "s1", "s2"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("rr order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainPreferenceSinksButStaysReachable(t *testing.T) {
|
||||
neg := fakeProv("neg", "n")
|
||||
good := fakeProv("good", "g")
|
||||
neg.pref.Store(-5)
|
||||
good.fail.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "n", Source: "neg"},
|
||||
{Tier: 0, Model: "g", Source: "good"},
|
||||
}, bySource(neg, good))
|
||||
s := New(0)
|
||||
resp, src, model, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
// higher pref (good) is tried first and hard-fails; the pass moves on to
|
||||
// the negative-pref slot, which sinks but stays reachable: with the real
|
||||
// provider its success would RecordSuccess (+1 pref, self-heal)
|
||||
if src != "neg" || model != "n" || resp.Content != "neg" {
|
||||
t.Fatalf("served src=%q model=%q content=%q", src, model, resp.Content)
|
||||
}
|
||||
if good.chatHits.Load() == 0 {
|
||||
t.Fatal("higher-pref slot must be tried first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainBusySkipsWithoutPenalty(t *testing.T) {
|
||||
a, b := fakeProv("s1", "a"), fakeProv("s2", "b")
|
||||
a.busy.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "a", Source: "s1"},
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
if src != "s2" {
|
||||
t.Fatalf("src = %q, want s2", src)
|
||||
}
|
||||
if a.chatHits.Load() == 0 {
|
||||
t.Fatal("busy slot must have been attempted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainAllBusyBoundedWaitThenNextTier(t *testing.T) {
|
||||
oldWait, oldPoll := busyWait, busyPoll
|
||||
busyWait, busyPoll = 60*time.Millisecond, 10*time.Millisecond
|
||||
t.Cleanup(func() { busyWait, busyPoll = oldWait, oldPoll })
|
||||
a, b, c := fakeProv("s1", "a"), fakeProv("s2", "b"), fakeProv("s3", "c")
|
||||
a.busy.Store(true)
|
||||
b.busy.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 5, Model: "a", Source: "s1"},
|
||||
{Tier: 5, Model: "b", Source: "s2"},
|
||||
{Tier: 4, Model: "c", Source: "s3"},
|
||||
}, bySource(a, b, c))
|
||||
s := New(0)
|
||||
t0 := time.Now()
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
el := time.Since(t0)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
if src != "s3" {
|
||||
t.Fatalf("src = %q, want s3 (downgrade after bounded wait)", src)
|
||||
}
|
||||
if el > time.Second {
|
||||
t.Fatalf("busy wait not bounded: %v", el)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainQuotaExhausted(t *testing.T) {
|
||||
a, b := fakeProv("s1", "a"), fakeProv("s2", "b")
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "a", Source: "s1", Quota: 100, Period: "hour"},
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
exhausted := func(sl *Slot) bool { return sl.Source == "s1" && sl.Quota > 0 }
|
||||
_, src, _, err := s.ChainChat(context.Background(), ch, chatReq(), exhausted)
|
||||
if err != nil {
|
||||
t.Fatalf("chain: %v", err)
|
||||
}
|
||||
if src != "s2" {
|
||||
t.Fatalf("src = %q, want s2 (quota slot dropped)", src)
|
||||
}
|
||||
if a.chatHits.Load() != 0 {
|
||||
t.Fatal("quota-exhausted slot must not be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainErrSummary(t *testing.T) {
|
||||
a, b, c := fakeProv("s1", "a"), fakeProv("s2", "b"), fakeProv("s3", "c")
|
||||
a.fail.Store(true)
|
||||
b.fail.Store(true)
|
||||
c.available.Store(false) // whole tier 1 cooling
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "a", Source: "s1"},
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
{Tier: 1, Model: "c", Source: "s3"},
|
||||
}, bySource(a, b, c))
|
||||
s := New(0)
|
||||
_, _, _, err := s.ChainChat(context.Background(), ch, chatReq(), nil)
|
||||
var ce *ChainErr
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("err = %v, want *ChainErr", err)
|
||||
}
|
||||
if len(ce.Tiers) != 2 || ce.Tiers[0].Source != "s1" || ce.Tiers[1].Source != "s2" {
|
||||
t.Fatalf("tiers = %+v", ce.Tiers)
|
||||
}
|
||||
if len(ce.Skipped) != 1 {
|
||||
t.Fatalf("skipped = %+v", ce.Skipped)
|
||||
}
|
||||
msg := ce.Error()
|
||||
if !strings.Contains(msg, "s1") || !strings.Contains(msg, "s2") || !strings.Contains(msg, "tier 1: no schedulable slot") {
|
||||
t.Fatalf("summary = %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainStreamFallsBackBeforeFirstChunk(t *testing.T) {
|
||||
a, b := fakeProv("s1", "a"), fakeProv("s2", "b")
|
||||
a.fail.Store(true)
|
||||
ch := BuildChain([]Rule{
|
||||
{Tier: 0, Model: "a", Source: "s1"},
|
||||
{Tier: 0, Model: "b", Source: "s2"},
|
||||
}, bySource(a, b))
|
||||
s := New(0)
|
||||
chunks, src, model, err := s.ChainChatStream(context.Background(), ch, chatReq(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("chain stream: %v", err)
|
||||
}
|
||||
if src != "s2" || model != "b" {
|
||||
t.Fatalf("src=%q model=%q", src, model)
|
||||
}
|
||||
var text string
|
||||
for ck := range chunks {
|
||||
text += ck.Content
|
||||
}
|
||||
if text != "s2" {
|
||||
t.Fatalf("text = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainResetCooldownAfterSwap(t *testing.T) {
|
||||
a := fakeProv("s1", "a")
|
||||
ch := BuildChain([]Rule{{Tier: 0, Model: "a", Source: "s1"}}, bySource(a))
|
||||
// a freshly built chain must schedule from index 0 (cursor starts at -1)
|
||||
if base := ch.Tiers[0].NextStart(); base != 0 {
|
||||
t.Fatalf("first start = %d, want 0", base)
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,17 @@ package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrBusy is the soft "source at capacity" sentinel shared by the provider
|
||||
// layer (returns it) and the scheduler layer (reacts to it): busy is not a
|
||||
// failure, so no cooldown/preference penalty is recorded, and gateways map
|
||||
// it to HTTP 429. Defined here so the scheduler does not depend on the
|
||||
// provider package (which pulls in the Lua runtime).
|
||||
var ErrBusy = errors.New("provider busy")
|
||||
|
||||
// ---- OpenAI wire request (gateway input) ----
|
||||
|
||||
type ChatRequest struct {
|
||||
|
||||
228
plan.md
Normal file
228
plan.md
Normal file
@ -0,0 +1,228 @@
|
||||
# ModelRouter 调度层重构方案(基于线上实测)
|
||||
|
||||
> 调研对象:192.168.2.60 生产实例(`/usr/local/bin/llmsproxy -config /etc/llmsproxy/config.yaml`,systemd 托管)
|
||||
> 调研时间:2026-08-10,审计文件 `/etc/llmsproxy/runtime.json.audit.jsonl`(3978 行 / 1042 条请求记录)
|
||||
|
||||
---
|
||||
|
||||
## 一、线上实测问题(证据链)
|
||||
|
||||
### P1(核心,用户实测)编辑 AUTO 优先级不重载退避状态
|
||||
- 证据:`/api/status` 实时返回 `qijiar available=false, live_available=true`、`zen available=false, live_available=true`。
|
||||
- 根因:`Core.SaveAutoRules`(core.go:232)只写 runtime.json,不重建 Provider;退避状态存在 `Provider.health`(provider.go:24-51),只有 `rebuildRegistry`(core.go:290)会重建。**用户在 WebUI 改完优先级后,被退避锁死的源依旧被跳过。**
|
||||
- 线上复现:AUTO 链 4 槽 `[qijiar/gpt-5.5→zen/flash-free→frank/gpt-5.6-sol→zen/nemotron]`,实时请求 `POST /v1/chat/completions {"model":"AUTO"}` → `{"error":{"message":"no provider available","type":"upstream_error"}}`,0ms 返回;其中 2 个源探活确认在线。
|
||||
|
||||
### P2 源级黑名单(粒度错)
|
||||
- 证据:`zen` 因 `429 FreeUsage` 退避(审计 ×4)→ 其 9 个配置模型全部不可调度;`gpt-5.5` 的 qijiar 一次失败 → 源整体跳过。
|
||||
- 根因:健康标志是 `(源)` 级而非 `(源, 模型)` 级(provider.go:24);一个模型失败黑掉全源。
|
||||
|
||||
### P3 永久黑名单(无法自愈)
|
||||
- 证据:`frank` 收到一次 `401 API_KEY_DISABLED` → `markPermanent()`(provider.go:48-51, 281-283);此后 AUTO 永远跳过它,且有 30 分钟周期的显式探测持续打它产生 502(502 又推高退避)。
|
||||
- 根因:`permanent` 无过期、无重置入口;显式请求路径不检查 `Available()`,照打不误。
|
||||
|
||||
### P4 并发打满 = 排队 60s,而非立即切换
|
||||
- 证据/源码:`Provider.Acquire`(provider.go:306-324)满则等待 `QueueTimeout`(默认 60s,config.go:154);AUTO 逐槽串行,打满的档位拖死整条链。
|
||||
- 无 `TryAcquire` 语义,路由层无法区分"忙"与"故障"。
|
||||
|
||||
### P5 探活与调度状态脱节
|
||||
- 探活(GET /models)不污染调度状态是正确设计,但**探活结果没有任何一条通道能恢复调度健康**——线上 qijiar/zen "探活通、调度死" 的矛盾即因此产生。
|
||||
|
||||
### P6 流式成功不清退避
|
||||
- 证据/源码:`ChatStream`(provider.go:406-490)全程无 `reportOK()`;流式源一旦退避,后续成功流也不复位。
|
||||
|
||||
### P7 两套 AUTO 逻辑并存,配置互相矛盾
|
||||
- 线上:config.yaml 里 qijiar priority 95/90/85、deepseek 40/30;runtime AUTO 链却是 `tier1 gpt-5.5 → tier2 zen/flash-free → tier3 frank/gpt-5.6-sol → tier4 zen/nemotron`。
|
||||
- `Registry.Resolve("AUTO")`(registry.go:83-121,按"源的最大模型 priority + 健康优先")与 `autoPlans`(chat.go:630,按 runtime 槽位)行为不一致;deepseek 全部模型不在 AUTO 链中,但运维以为"priority 高"会被 AUTO 选中。
|
||||
|
||||
### P8 错误码策略粗糙
|
||||
- `deepseek` 402 欠费 ×17:走 `ReportStatus` 不触发任何退避也不在 UI 提示(402 不属于 401/403/429/5xx 分支),欠费源被无限重试。
|
||||
- 400 Invalid schema ×19(客户端工具定义问题)同样无区分。
|
||||
- 链全灭时只返回 `no provider available`(12 条),无分槽错误汇总,无法定位是谁挂了。
|
||||
|
||||
### P9 其他(顺带)
|
||||
- 种子 admin key `sk-gw-local-0001` 未更换(README 明确要求换);`listen: 0.0.0.0:8081` 全端口暴露。
|
||||
- AUTO 链只有 4 槽,13+ 配置模型不参与 AUTO,且 webui 编辑链后无任何"健康复位"提示。
|
||||
- 审计文件 jsonl 无限增长(当前 547KB);`AppendAudit` 每事件一次文件 open。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标架构(定稿)
|
||||
|
||||
### 2.1 分层(横向三层 + 状态基座横切)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ 直连调度 AUTO 调度 │ ← 同级,共享底座
|
||||
│ source:model 直派 链快照 + 偏好表 │ 直连失败即返回(不重试)
|
||||
├────────────────────────────────────────────────┤
|
||||
│ 源抽象层(每源) │
|
||||
│ · 信号量 = max_concurrent,TryAcquire 非阻塞 │ ← 唯一"忙/闲"判定
|
||||
│ · Chat / ChatStream / Image / 事件上报 │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ adapter 池层(每适配器,启动预热) │
|
||||
│ worker = Σ(使用该适配器的源 × max_concurrent) │
|
||||
└────────────────────────────────────────────────┘
|
||||
▲ 状态基座(不属任何层)
|
||||
ModelState 表 (source,model) → {pref, failCount, cooldownUntil}
|
||||
tier 游标表 tier → atomic next(记录上次分配位置)
|
||||
```
|
||||
|
||||
事件流:所有成功/失败/冷却由**源抽象层**上报到状态基座;auto 与直连都只读。直连失败同样写入状态。
|
||||
|
||||
### 2.2 数据结构
|
||||
|
||||
```go
|
||||
// 调度链:保存时整体新建、原子替换;调度期只读快照
|
||||
type Chain struct { Tiers []TierNode } // 按 tier 降序
|
||||
type TierNode struct { Tier int; Slots []*Slot }
|
||||
type Slot struct { // 静态配置,链构建时冻结配额
|
||||
Model, Source string
|
||||
Kind string
|
||||
Quota int64; Period string; Hours int64
|
||||
state *ModelState // 指针 → 跨链存活
|
||||
}
|
||||
|
||||
// 状态基座(key=(source,model),配置移除后回收)
|
||||
type ModelState struct {
|
||||
pref atomic.Int64 // +1/-5,clamp[-20,+20]
|
||||
failCount atomic.Int64
|
||||
cooldownUntil atomic.Int64 // 惰性指数退避,无定时器
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 调度算法(AUTO)
|
||||
|
||||
```
|
||||
snap := chain.Snapshot() // O(n) 拷指针,调度期链只读
|
||||
for tier := snap.Tiers 降序 {
|
||||
初筛 := 剔除 [冷却中 | 配额超限(slot.Quota>0 && windowTokens>=Quota)] 的槽
|
||||
if 初筛为空 → 该 tier 顺延,记录原因
|
||||
base := cursor[tier].Add(1) // per-tier 原子游标(记录上次位置)
|
||||
ordered := 稳定排序(初筛, 按 pref 降序) // 负面模型沉底
|
||||
for i := base; i < base+len(ordered); i++ {
|
||||
slot := ordered[i % n]
|
||||
if slot.state 冷却中 → continue // 硬跳过(复验)
|
||||
if !src.TryAcquire() → continue // 忙 = 软跳过(不记分)
|
||||
resp, err := src.Chat(slot.model)
|
||||
if err != nil {
|
||||
src.Release()
|
||||
state.RecordFailure() // failCount++, pref-5, 冷却 5s·2^n(≤30min)
|
||||
continue // 单请求内不重试已失败槽
|
||||
}
|
||||
state.RecordSuccess() // pref+1, failCount/cooldown 清零
|
||||
return resp, slot.model, slot.source
|
||||
}
|
||||
}
|
||||
return 503 { 各 tier 错误汇总 } // 全部档位失败才报错,可定位
|
||||
```
|
||||
|
||||
关键语义:
|
||||
- **主序 = 游标轮转(均衡),偏好只做同起点排序(自适应)**;负偏好"沉底不跳过"——仅当同 tier 无非负候选时才尝试负面模型,成功 +1 自愈。**无永久黑名单、无定时器**。
|
||||
- **忙 ≠ 失败**:不扣分、不计冷却;同 tier 全忙 → 有界等待(≤2s 轮询 TryAcquire,尊重 ctx)再顺延,避免高 QPS 时全线降级。
|
||||
- **冷却是唯一硬跳过**:`cooldownUntil = lastFail + min(5s·2^failCount, 30min)`,必然到期 → 自愈。
|
||||
- 401/403:failCount 一次打高(不设永久位),可被冷却到期/手动重置恢复。
|
||||
- 流式:失败→切换仅限首 chunk 前;首块后固定。首块前失败 -5,正常结束 +1。
|
||||
- 生图:不进 AUTO 链,直连 `kind:image` 模型,同一状态机制。
|
||||
|
||||
### 2.4 生命周期(修 P1)
|
||||
|
||||
| 事件 | 动作 |
|
||||
|---|---|
|
||||
| WebUI 保存 AUTO 链 | 构造新 `Chain` → 写锁原子替换 → **链内全部 ModelState 冷却清零(偏好保留)** |
|
||||
| 增删/编辑源 | `rebuildRegistry` 重建 Provider,ModelState 复用/回收 |
|
||||
| (source,model) 移出配置 | 回收其 ModelState(偏好一并丢弃) |
|
||||
| 进行中请求 | 不受 swap 影响(入口 Snapshot 已拷贝引用) |
|
||||
|
||||
### 2.5 直连
|
||||
|
||||
```
|
||||
p := resolve(source, model) // 唯一归属,无 AUTO 逻辑
|
||||
p==nil → 400/404;!TryAcquire → 429 busy(快速失败)
|
||||
成功/失败 → RecordSuccess/RecordFailure(同样写状态基座),返回,不重试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、实施计划(分阶段上线)
|
||||
|
||||
### Phase 0 — 止血热修(改动最小,当天可上)
|
||||
1. `core.go`:`SaveAutoRules` 成功后调用 `rebuildRegistry()`;给 `Provider` 增加 `ResetHealth()`(清 failCount/permanent/cooldown),rebuild 时顺带重置。
|
||||
2. `provider.go`:`ChatStream` 成功(收到 `[DONE]` 或正常结束)调用 `reportOK()`。
|
||||
3. 状态页:`/api/status` 增加 `last_backoff`、`permanent` 展示 + admin 可"重置源健康"按钮(调用 ResetHealth)。
|
||||
- 验证:改优先级 → AUTO 立即按新链调度,qijiar/zen 场景恢复;回归 `go test`。
|
||||
|
||||
### Phase 1 — 状态基座落地(provider.go 重构)
|
||||
1. 引入 `ModelState`(每 (source,model):pref/failCount/cooldownUntil,原子)。
|
||||
2. `Provider` 增加 `TryAcquire(ctx) error`(非阻塞)与 `RecordFailure(model, code)` / `RecordSuccess(model)`;`ReportStatus` 改为按模型记账与 401/403 不再永久化。
|
||||
3. 删除 `permanent` 语义(由有界冷却 + 重置通道取代)。
|
||||
- 验证:单测(退避按模型隔离、429/401 行为、TryAcquire 满即返)。
|
||||
|
||||
### Phase 2 — 调度层重写(scheduler.go + chat.go)
|
||||
1. 新增 `Chain/TierNode/Slot` 与 Snapshot/原子替换;`autoPlans`、`rotateSameTier` 删除,`singleChatAuto`/`streamChatAuto` 按 2.3 伪码重写。
|
||||
2. `Registry.Resolve` 移除 AUTO 排序/健康优先职责(只留归属 + pin 解析;`AUTOChain`/`Default` 不再用于调度)。
|
||||
3. 503 响应携带分槽错误汇总(哪个 tier 哪个源什么错)。
|
||||
4. 同 tier 全忙时 ≤2s 有界等待再降级。
|
||||
- 验证:scheduler 单测(tier 分桶、游标均衡、偏好沉底自愈、配额初筛、忙不记分);e2e 增加"上游 429 → 切同 tier → 再切下 tier"、"编辑优先级后退避清零"用例。
|
||||
|
||||
### Phase 3 — 收尾
|
||||
1. UI:优先级页展示链上模型的冷却/偏好状态;状态页按模型展示健康。
|
||||
2. WebUI 保存链时前端提示"健康状态已复位"。
|
||||
3. 审计:错误码分类(402 欠费、400 schema 计入独立统计),jsonl 轮转(按天/大小)。
|
||||
4. 运维:更换 admin key;`listen` 收敛到内网地址(0.0.0.0:8081 → 127.0.0.1 或内网 IP + 反向代理)。
|
||||
|
||||
### 回归与上线
|
||||
- 构建:`go build -tags luajit`(生产机 Debian amd64 已具备工具链,可直接在 60 上编译);本地 Windows 需先补 LuaJIT 库才能链接。
|
||||
- 测试:`go test -tags luajit ./...` 全绿后按 Phase 0→3 灰度,每个 Phase 观察审计中 `no provider available` 计数与 502 分布。
|
||||
- 观察指标:`no provider available` 计数归零;qijiar/zen 由 `available=false` 恢复;`zen 429` 触发时仅 zen 对应模型短冷却,deepseek/qijiar 不受牵连。
|
||||
|
||||
---
|
||||
|
||||
## 四、与既有代码的对应改动文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| internal/provider/provider.go | health→ModelState 表;TryAcquire;ReportStatus/ResetHealth;ChatStream reportOK |
|
||||
| internal/provider/registry.go | 删除 Resolve(AUTO) 排序/健康逻辑,保留归属/pin |
|
||||
| internal/scheduler/scheduler.go | 新增 Chain/Slot/游标/偏好排序迭代器 |
|
||||
| internal/gateway/chat.go | 删除 autoPlans/rotateSameTier,重写单次/流式 AUTO;错误汇总 |
|
||||
| internal/core/core.go | SaveAutoRules 原子换链 + 冷却清零;ModelState 生命周期 |
|
||||
| internal/gateway/api.go / keys.go | 状态页/优先级页 UI 展示与重置接口 |
|
||||
| e2e/e2e_test.go 等 | 新场景用例 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实施状态追踪(每项改动后更新)
|
||||
|
||||
### Phase 0 — 止血热修
|
||||
|
||||
- [x] **P0-1(2026-08-10)`core.go`**:`SaveAutoRules` 持久化后调用 `rebuildRegistry()`——新建 Provider 即退避归零,**编辑优先级立即生效**(修 P1 主诉);新增 `Core.ResetHealth()` 供管理端手动清退避。
|
||||
- [x] **P0-2(2026-08-10)`provider.go`**:新增 `Provider.ResetHealth()` / `Provider.HealthInfo()`(failCount/cooldownUntil/permanent 只读暴露);`ChatStream` 流式正常结束(`[DONE]`/EOF、非客户端断开、非读错误)调用 `reportOK()`——流式成功可恢复退避(修 P6)。
|
||||
- [x] **P0-3(2026-08-10)`registry.go` + `server.go`**:`SourceStatus` 增加 `fail_count`/`backoff_until`/`permanent` 字段(状态页可分辨"探活通但调度退避");新增 `POST /api/status/reset`(admin 专属,写审计)。
|
||||
- [x] **P0-4(2026-08-10)验证**:`go build ./internal/...` 通过;`go test ./internal/config/...` 通过(Windows 缺 LuaJIT 库,带 cgo 的包无法本地链接,待生产机验证)。
|
||||
|
||||
> 说明:P0-1 采用"重建 Provider"实现退避归零(非目标架构的 ModelState 粒度),属临时止血;Phase 1 引入 per-(源,模型) ModelState 后,`ResetHealth` 语义将迁移到模型级。
|
||||
|
||||
### Phase 1 — 状态基座落地
|
||||
- [x] **P1-1(2026-08-10)`provider.go`**:`ModelState` 表落地(pref±1/-5、failCount、cooldownUntil 原子,惰性求值无定时器);新增 `ErrBusy`、`TryAcquire`(非阻塞,满即返)、`ModelAvailable(model)`、`RecordFailure(model, code)` / `RecordSuccess(model)`、`Provider.Pref(model)`;`ReportStatus` 改按模型记账(401/403 → 打满档冷却 + 双倍偏好惩罚,**不再永久化**;5xx/429 → 指数退避;400/402 不惩罚);`ResetHealth` 清全源模型状态;`HealthInfo` 源级聚合(permanent 恒 false);`Chat/ChatStream/Image` 全部按命中模型记账 + 内部 `Acquire` 换 `TryAcquire`(忙即 429,不再排队 60s,修 P4);流式干净收尾 `RecordSuccess`(修 P6)。
|
||||
- [x] **P1-2(2026-08-10)`chat.go`**:直连/生图路径 `errors.Is(err, ErrBusy)` → HTTP 429(`upstreamErrStatus`);AUTO 槽改用 `ModelAvailable(slot.model)` 模型级冷却跳过(frank 401 后其模型不被 AUTO 反复打,修 P3 的一半——永久黑名单已随 P1-1 移除)。
|
||||
- [x] **P1-3(2026-08-10)单测(`provider_test.go`)**:模型级退避隔离(m1 失败 m2 照常)、401 → failCount 打满 capN + 冷却 ~30min + 偏好 -10(非永久,reset 即恢复)、单次失败冷却 ≈5s 且成功复位 +1、TryAcquire 满即返、Chat 忙时快速 ErrBusy、流式成功清理冷却(P6 回归)。
|
||||
- [x] **P1-4(2026-08-10)本地验证**:`go build ./internal/...`、`go vet ./internal/...`、`go test ./internal/config/...` 全绿;provider/gateway 测试因 Windows 缺 `-llua` 仅静态检查通过,待生产机 `-tags luajit` 全量跑(同 P0-4 约束)。
|
||||
- [ ] 生产机(192.168.2.60)`go test -tags luajit ./internal/provider/...` 回归 — 待 Phase 2/3 完成后一并部署验证。
|
||||
|
||||
### Phase 2 — 调度层重写
|
||||
- [x] **P2-1(2026-08-10)`scheduler.go`**:`Chain/TierNode/Slot/Rule` 落地(tier 降序;槽静态配置,同 tier 保持配置序;per-tier `next atomic.Int64` 游标始 -1,首请求从配置序开始);`BuildChain` 丢弃 provider 解析失败的槽;`runTier` 按 2.3 语义重写——冷却复验硬跳过、busy 软跳过不记分、**硬失败记账后同档继续下一槽**(单请求不重试已失败槽)、整档遍历完才顺延;`chainDrive` 初筛(配额/冷却)→ 同 tier 按 `Pref` 稳定降序排序 → `NextStart()` 起步轮转 → 全忙/全冷却有界等待(`busyWait` 2s/`busyPoll` 100ms,期间刷新冷却)再降级;`ChainErr` 携带各档 `TierError` + 顺延原因,Error() 输出 `all auto tiers failed: tier N src/model: err; ...`;`ChainChat/ChainChatStream` 返回 (resp, source, model, err);直连 `Chat/ChatStream/Image` 保持不变;**scheduler 不再 import provider**(`FromRegistry` 移入 gateway 为 `toScheduler`),scheduler 单测不拉 LuaJIT 链接。
|
||||
- [x] **P2-2(2026-08-10)`registry.go` + `core.go`**:`Registry.Resolve` 移除 AUTO 排序/健康优先(AUTO→按配置序全量仅用于生图/tool 锚定;未知模型→nil→gateway 404;`AUTOChain`/`Default` 删除);`Core` 持 `autoChain atomic.Pointer[scheduler.Chain]` + `AutoChain()`;`buildAutoChain`(过滤 image-kind 与已不存在槽,**槽 Source 规范化为所有权源**使汇总/audit/配额窗口键一致)随 `rebuildRegistry` 与 `SaveAutoRules` 重建;`SaveAutoRules` = 持久化 → 原子换链 → **链内每槽 `ResetModelCooldown`(偏好保留,不重建 Provider)**——修 P1 主诉(编辑优先级立即生效)。
|
||||
- [x] **P2-3(2026-08-10)`chat.go`**:AUTO 分支改走 `AutoChain()`+`quotaExhausted`(`Quota<=0` 不过滤;`AutoPeriodSeconds`+`stats.WindowTokens` 实时判定);`singleChatAuto`/`streamChatAuto` 按 `ChainChat`/`ChainChatStream` 重写,总失败在**任何 SSE 字节前**写 JSON;`upstreamErrStatus`:`ChainErr`→503(错误消息即分档汇总)、`ErrBusy`→429、其余→502;失败记录取首个 `TierError` 填 audit source/model;直连无候选→404 `model_not_found`。
|
||||
- [x] **P2-4(2026-08-10)单测(`scheduler_test.go`)**:tier 分桶/降序、同 tier 游标轮转交替(s1,s2,s1,s2)、负偏好沉底仍可达(硬失败换槽后由负面槽承接)、busy 跳过不记分、整档全忙有界等待(实测 <1s)后降级、配额耗尽槽不调度、全灭 503 汇总(Tiers+Skipped 文本断言)、流式首 chunk 前失败换槽、游标首帧从 0 起。
|
||||
- [x] **P2-5(2026-08-10)本地验证(Windows 已补齐 Lua)**:把 golua 自带 Lua 5.1 头对应的源码编成 `liblua.a` 放入 golua 模块目录(golua 官方 Windows 做法),本机 `go build/vet/test ./...` 全绿——**provider/gateway/lua/e2e 全部首次真正跑通**,并借此揪出三处从未被发现的存量问题:① `RecordFailure(auth)` 只把 failCount 上限用于冷却算式、未落盘计数器(已修:auth 时 `failCount.Store(backoffCapN)`);② gateway 测试种子 key 未进 runtime store 导致全 401(已修:测试 cfg 补 `GatewayKeys`);③ e2e failover 用例只有一个 chat 源、AUTO 全灭必然 503(已修:新增第二 chat 源 `fallback`,真故障转移);e2e `buildBinary` Windows 回退无 tag 构建(bundled Lua,透传适配器行为一致),生产机仍优先 `-tags luajit`。
|
||||
- [x] **P2-6(2026-08-10)Phase 2 场景测试**:gateway 新增——同 tier 硬失败顺延承接(`TestChatAutoChainTierFailover`)、全灭 503 含 `a/a-m` 分档汇总(`TestChatAutoChain503Summary`)、配额耗尽槽跳过且不再打上游(`TestChatAutoQuotaSkip`)、`PUT /api/auto` 后冷却立即复位并恢复调度(`TestAutoSaveResetsCooldown`,修 P1 回归);e2e 新增 `TestEndToEndAuto503`(真实二进制 503 汇总)。全部本地跑通。
|
||||
- [ ] **P2-7**:生产机(192.168.2.60)`go test -tags luajit ./...` 最终回归 + 灰度部署(luajit 与 bundled Lua 的适配器行为差异由生产验证兜底)。
|
||||
|
||||
### Phase 3 — 收尾
|
||||
- [x] **P3-1(2026-08-10)UI 链上健康展示**:`GET /api/auto` 增加 `states`(`Core.AutoSlotStates` 遍历当前 Chain 槽 × `Provider.ModelHealthInfo`:pref/failCount/cooldownUntil/cooling);优先级页每块按 `model|source` 渲染徽标(冷却红/失败橙×N/±偏好蓝,title 说明);状态页新增"状态码分布"卡片。
|
||||
- [x] **P3-2(2026-08-10)保存链健康复位提示**:`saveSort` toast 变更为 `排序已保存并热重载 · 链上冷却已复位`(zh/en)。
|
||||
- [x] **P3-3(2026-08-10)审计分类与 jsonl 轮转**:`Stats.byStatus map[int]*Stat`(402 欠费/400 schema 等按状态码独立计数,不触发 provider 退避,`Snapshot.by_status` 有序输出);audit 文件超 `auditRotateBytes`(64MB, var 可测) 轮转 `rename <path>.<unix>.old` 并保留最新 `auditKeepOld`(10) 份——`rotateAuditLocked` 持 mu 在 `Record`/`AppendAudit` 内触发。
|
||||
- [x] **P3-4(2026-08-10)运维项(代码部分)**:`main.go` 启动告警——gateway_keys 为空 / 命中种子 key(sk-gw-local-0001 等)提示轮换、listen 绑定 0.0.0.0/:: 提示收敛内网。生产实践(换 admin key、内网绑定)随本次上线执行。
|
||||
- [x] **P3-5(2026-08-10)测试**:新增 `stats_test.go`(by_status 断言、轮转→保留上限→Record 路径轮转);gateway 新增 `TestAutoStatesReportChainHealth`(states 契约:失败后 fail_count>0+cooling,PUT 复位归零);`go vet ./...` + `go test ./...` 全绿。
|
||||
- [x] **P3-6(2026-08-10)WebUI 右键菜单无法关闭(用户实测)**:根因——关闭依赖 `document` 冒泡阶段 once-click 监听,而优先级页块/密钥砖自己的 click 处理 `stopPropagation()` 阻断冒泡 → 菜单永不关闭;修复:改为 **document 捕获阶段**全局 `click`+`mousedown` 关闭(捕获先于一切目标处理器,stopPropagation 无法拦截),优先级页与密钥页共用 `showCtx` 一并修复。
|
||||
- [ ] **P3-7**:推送 origin → 生产机 pull → `-tags luajit` 全量回归(含 P2-7)→ 部署 `/usr/local/bin/llmsproxy` + 重启 service → 观察。
|
||||
Reference in New Issue
Block a user