mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-21 09:28:00 +00:00
fix: AUTO tier order ascending + stats/CSV export completeness + UI key-view toggle & exit affordance
- scheduler: BuildChain sorts tiers ascending so tier 1 (highest priority) is tried first; previously descending inverted the chain (P10-1..P10-5) - stats/api: handleStatsAPI limit→20000; CSV reads full audit via new Stats.AuditRecords (includes *.old rotation); key_names mapped by keyID() masked key; non-admin filter and keys-csv use masked keys - server: ring buffer NewStats(10000) - ui: dash-row card scroll area moved to table container (fix overflow below card); key view toggle (re-click same key returns to global) + kpi-exit affordance; rec-exit span - chat: chain-failure record now surfaces first failed tier; AUTO comment sync
This commit is contained in:
@ -130,14 +130,15 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
limit := 500
|
limit := 500
|
||||||
if v := r.URL.Query().Get("limit"); v != "" {
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20000 {
|
||||||
limit = n
|
limit = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key := r.URL.Query().Get("key")
|
key := r.URL.Query().Get("key")
|
||||||
if reqRole(r.Context()) != "admin" {
|
if reqRole(r.Context()) != "admin" {
|
||||||
// user keys may only see their own usage - use full key for internal filtering
|
// user keys may only see their own usage - records and aggregates are
|
||||||
key = reqKey(r.Context())
|
// keyed by the masked id (keyID), so filter on that masked form.
|
||||||
|
key = keyID(reqKey(r.Context()))
|
||||||
}
|
}
|
||||||
if r.URL.Query().Get("export") == "csv" {
|
if r.URL.Query().Get("export") == "csv" {
|
||||||
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
||||||
@ -150,10 +151,10 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
cw := csv.NewWriter(w)
|
cw := csv.NewWriter(w)
|
||||||
names := map[string]string{}
|
names := map[string]string{}
|
||||||
for _, k := range g.core.ListKeys() {
|
for _, k := range g.core.ListKeys() {
|
||||||
names[k.Key] = k.Name
|
names[keyID(k.Key)] = k.Name
|
||||||
}
|
}
|
||||||
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"})
|
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"})
|
||||||
for _, rec := range g.stats.Records(from, to, key) {
|
for _, rec := range g.stats.AuditRecords(from, to, key) {
|
||||||
_ = cw.Write([]string{
|
_ = cw.Write([]string{
|
||||||
time.UnixMilli(rec.Time).Format(time.RFC3339),
|
time.UnixMilli(rec.Time).Format(time.RFC3339),
|
||||||
rec.Key,
|
rec.Key,
|
||||||
@ -180,7 +181,13 @@ if r.URL.Query().Get("export") == "keys-csv" {
|
|||||||
// Use the same key filtering as the JSON API
|
// Use the same key filtering as the JSON API
|
||||||
exportKey := r.URL.Query().Get("key")
|
exportKey := r.URL.Query().Get("key")
|
||||||
if reqRole(r.Context()) != "admin" {
|
if reqRole(r.Context()) != "admin" {
|
||||||
exportKey = reqKey(r.Context())
|
exportKey = keyID(reqKey(r.Context()))
|
||||||
|
}
|
||||||
|
// by_key rows are keyed by the masked id (keyID); build a masked-id ->
|
||||||
|
// record map so names/roles/models resolve for the export.
|
||||||
|
byMasked := map[string]config.GWKey{}
|
||||||
|
for _, k := range g.core.ListKeys() {
|
||||||
|
byMasked[keyID(k.Key)] = k
|
||||||
}
|
}
|
||||||
snap := g.stats.Snapshot(0, exportKey)
|
snap := g.stats.Snapshot(0, exportKey)
|
||||||
byKeyRaw, ok := snap["by_key"].([]StatsRow)
|
byKeyRaw, ok := snap["by_key"].([]StatsRow)
|
||||||
@ -188,8 +195,7 @@ if r.URL.Query().Get("export") == "keys-csv" {
|
|||||||
byKeyRaw = []StatsRow{}
|
byKeyRaw = []StatsRow{}
|
||||||
}
|
}
|
||||||
for _, row := range byKeyRaw {
|
for _, row := range byKeyRaw {
|
||||||
key := row.Name
|
keyInfo, found := byMasked[row.Name]
|
||||||
keyInfo, found := g.core.FindKey(key)
|
|
||||||
name := ""
|
name := ""
|
||||||
role := ""
|
role := ""
|
||||||
models := ""
|
models := ""
|
||||||
@ -237,7 +243,7 @@ if r.URL.Query().Get("export") == "keys-csv" {
|
|||||||
snap := g.stats.Snapshot(limit, key)
|
snap := g.stats.Snapshot(limit, key)
|
||||||
keyNames := map[string]string{}
|
keyNames := map[string]string{}
|
||||||
for _, k := range g.core.ListKeys() {
|
for _, k := range g.core.ListKeys() {
|
||||||
keyNames[k.Key] = k.Name
|
keyNames[keyID(k.Key)] = k.Name
|
||||||
}
|
}
|
||||||
snap["key_names"] = keyNames
|
snap["key_names"] = keyNames
|
||||||
writeJSON(w, http.StatusOK, snap)
|
writeJSON(w, http.StatusOK, snap)
|
||||||
|
|||||||
@ -648,10 +648,11 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// singleChatAuto runs a non-streaming AUTO request down the chain (see
|
// singleChatAuto runs a non-streaming AUTO request down the chain (see
|
||||||
// scheduler.ChainChat): tiers descending, per-tier round-robin ordered by
|
// scheduler.ChainChat): tiers ascending (tier 1 = highest priority first),
|
||||||
// preference, cooldown as the only hard skip, busy slots skipped without
|
// per-tier round-robin ordered by preference, cooldown as the only hard skip,
|
||||||
// penalty and a bounded busy wait. When every tier fails, the response is a
|
// busy slots skipped without penalty and a bounded busy wait. When every
|
||||||
// 503 carrying the per-tier error summary (which source/model failed why).
|
// 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) {
|
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
|
rec.LatMs = 0
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
|
|||||||
@ -38,7 +38,7 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
st := NewStats(3000)
|
st := NewStats(10000)
|
||||||
if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" {
|
if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" {
|
||||||
st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl")
|
st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -395,6 +395,51 @@ func (s *Stats) Records(from, to int64, key string) []Req {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuditRecords returns every request row from the audit file plus its rotated
|
||||||
|
// .old files within the [from,to] unix-millisecond window, optionally for one
|
||||||
|
// masked key id. Unlike Records it is not bounded by the in-memory ring, so it
|
||||||
|
// yields a complete history for CSV export regardless of restarts or churn.
|
||||||
|
func (s *Stats) AuditRecords(from, to int64, key string) []Req {
|
||||||
|
s.mu.Lock()
|
||||||
|
path := s.auditPath
|
||||||
|
s.mu.Unlock()
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files := []string{path}
|
||||||
|
if olds, err := filepath.Glob(path + ".*.old"); err == nil {
|
||||||
|
files = append(files, olds...)
|
||||||
|
}
|
||||||
|
var out []Req
|
||||||
|
for _, p := range files {
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
var r Req
|
||||||
|
if json.Unmarshal(sc.Bytes(), &r) != nil || r.Type == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key != "" && r.Key != key {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if from > 0 && r.Time < from {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if to > 0 && r.Time > to {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot returns the whole dashboard payload; when key != "" the records
|
// Snapshot returns the whole dashboard payload; when key != "" the records
|
||||||
// and aggregate views are restricted to that gateway key.
|
// and aggregate views are restricted to that gateway key.
|
||||||
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||||
|
|||||||
@ -107,11 +107,17 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
|
|||||||
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
|
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
|
||||||
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
|
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
|
||||||
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:3px; }
|
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:3px; }
|
||||||
|
.kpi.kpi-exit { cursor:pointer; border-color:var(--err); }
|
||||||
|
.kpi.kpi-exit:hover { border-color:var(--err); box-shadow:0 0 0 3px rgba(244,67,54,.15); }
|
||||||
|
.kpi.kpi-exit .k-lab::after { content:'×'; color:var(--err); font-weight:800; font-size:15px; margin-left:auto; }
|
||||||
.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; }
|
.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; }
|
||||||
.dot.err { background:var(--err); }
|
.dot.err { background:var(--err); }
|
||||||
@keyframes blink { 50% { opacity:.25; } }
|
@keyframes blink { 50% { opacity:.25; } }
|
||||||
.dash-row { display:flex; gap:12px; margin-bottom:18px; }
|
.dash-row { display:flex; gap:12px; margin-bottom:18px; }
|
||||||
.dash-row .card { flex:1; min-width:0; }
|
.dash-row .card { flex:1; min-width:0; display:flex; flex-direction:column; max-height:440px; }
|
||||||
|
.dash-row .card h2 { flex:0 0 auto; }
|
||||||
|
.dash-row .card > div { flex:1 1 auto; min-height:0; overflow:auto; -webkit-overflow-scrolling:touch; }
|
||||||
|
.dash-row .card > div table thead th { position:sticky; top:0; background:var(--card); z-index:1; }
|
||||||
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }
|
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||||
td .okc { color:var(--ok); font-weight:600; }
|
td .okc { color:var(--ok); font-weight:600; }
|
||||||
td .errc { color:var(--err); font-weight:600; }
|
td .errc { color:var(--err); font-weight:600; }
|
||||||
@ -482,7 +488,7 @@ const STR = {
|
|||||||
sortCooling:'冷却', sortFail:'失败', sortHealthTip:'冷却 / 失败次数 / 偏好分 实时状态', sortHealthReset:'链上冷却已复位',
|
sortCooling:'冷却', sortFail:'失败', sortHealthTip:'冷却 / 失败次数 / 偏好分 实时状态', sortHealthReset:'链上冷却已复位',
|
||||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
|
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
|
||||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量',
|
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量', exitKeyView:'退出该密钥视图',
|
||||||
dashStatus:'状态码分布', thCode:'状态码', statusTag:'状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)',
|
dashStatus:'状态码分布', thCode:'状态码', statusTag:'状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)',
|
||||||
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
||||||
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
||||||
@ -538,7 +544,7 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
|||||||
sortCooling:'cooling', sortFail:'fail', sortHealthTip:'live cooldown / failures / preference score', sortHealthReset:'chain cooldowns reset',
|
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',
|
sortSource:'source', sortPrio:'priority %s', sortEmpty:'no models in this source',
|
||||||
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
|
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
|
||||||
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download',
|
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', expKeysCsv:'Export key usage', exitKeyView:'Exit this key view',
|
||||||
dashStatus:'Status codes', thCode:'Code', statusTag:'Per-status aggregates — 402 quota / 400 schema errors are counted here but never back off the provider',
|
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',
|
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
|
||||||
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
|
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
|
||||||
@ -672,7 +678,7 @@ async function renderStatus() {
|
|||||||
</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('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>${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="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><span id="rec-exit"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
|
||||||
<div class="filter-line">
|
<div class="filter-line">
|
||||||
<span class="muted">${t('recFilter')}</span>
|
<span class="muted">${t('recFilter')}</span>
|
||||||
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
|
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
|
||||||
@ -690,15 +696,16 @@ async function renderStatus() {
|
|||||||
await paintStats();
|
await paintStats();
|
||||||
statsTimerId = setInterval(paintStats, 3000);
|
statsTimerId = setInterval(paintStats, 3000);
|
||||||
}
|
}
|
||||||
function renderKeySelect(keys) {
|
function renderKeySelect(keys, keyNames) {
|
||||||
const sel = $('#rec-key');
|
const sel = $('#rec-key');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const cur = sel.value;
|
const cur = sel.value;
|
||||||
|
const nm = (k) => (keyNames && keyNames[k] ? keyNames[k] + ' · ' + k : k);
|
||||||
sel.innerHTML = `<option value="">${esc(t('allKeys'))}</option>` +
|
sel.innerHTML = `<option value="">${esc(t('allKeys'))}</option>` +
|
||||||
keys.map(k => `<option value="${escAttr(k)}">${esc(k)}</option>`).join('');
|
keys.map(k => `<option value="${escAttr(k)}">${esc(nm(k))}</option>`).join('');
|
||||||
if (cur) sel.value = cur;
|
if (cur) sel.value = cur;
|
||||||
}
|
}
|
||||||
function renderKeyF(v) { statsKeyF = v; paintStats(); }
|
function renderKeyF(v) { statsKeyF = (statsKeyF === v) ? '' : v; paintStats(); }
|
||||||
function openExportModal() {
|
function openExportModal() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ago = d => { const x = new Date(now); x.setDate(x.getDate() - d); return x.toISOString().slice(0, 10); };
|
const ago = d => { const x = new Date(now); x.setDate(x.getDate() - d); return x.toISOString().slice(0, 10); };
|
||||||
@ -731,14 +738,14 @@ function downloadKeysCsv() {
|
|||||||
}
|
}
|
||||||
async function paintStats() {
|
async function paintStats() {
|
||||||
try {
|
try {
|
||||||
const q = '/api/stats?limit=500' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
|
const q = '/api/stats?limit=2000' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
|
||||||
const st = await api(q);
|
const st = await api(q);
|
||||||
const tot = st.total || {};
|
const tot = st.total || {};
|
||||||
const okr = tot.reqs ? Math.round(tot.ok * 100 / tot.reqs) : 0;
|
const okr = tot.reqs ? Math.round(tot.ok * 100 / tot.reqs) : 0;
|
||||||
const avg = fmtLat(tot.latency_sum_ms, tot.reqs);
|
const avg = fmtLat(tot.latency_sum_ms, tot.reqs);
|
||||||
const kpi = $('#kpi-row');
|
const kpi = $('#kpi-row');
|
||||||
if (kpi) kpi.innerHTML = `
|
if (kpi) kpi.innerHTML = `
|
||||||
<div class="kpi"><div class="k-lab">${t('kpiActive')} <span class="dot"></span></div><div class="k-val">${st.active || 0}</div><div class="k-sub">${statsKeyF ? esc(statsKeyF) : ''}</div></div>
|
<div class="kpi${statsKeyF ? ' kpi-exit' : ''}" title="${statsKeyF ? t('exitKeyView') : ''}" onclick="${statsKeyF ? `renderKeyF('${escAttr(statsKeyF)}')` : ''}"><div class="k-lab">${t('kpiActive')} <span class="dot"></span></div><div class="k-val">${st.active || 0}</div><div class="k-sub">${statsKeyF ? esc(st.key_names ? (st.key_names[statsKeyF] ? st.key_names[statsKeyF] + ' · ' : '') + statsKeyF : statsKeyF) : ''}</div></div>
|
||||||
<div class="kpi"><div class="k-lab">${t('kpiReqs')}</div><div class="k-val">${fmtN(tot.reqs)}</div><div class="k-sub">${t('kpiOk')} <b class="${okr >= 90 ? 'okc' : 'errc'}">${okr}%</b></div></div>
|
<div class="kpi"><div class="k-lab">${t('kpiReqs')}</div><div class="k-val">${fmtN(tot.reqs)}</div><div class="k-sub">${t('kpiOk')} <b class="${okr >= 90 ? 'okc' : 'errc'}">${okr}%</b></div></div>
|
||||||
<div class="kpi"><div class="k-lab">${t('kpiTokens')}</div><div class="k-val">${fmtTok(tot.tokens)}</div>
|
<div class="kpi"><div class="k-lab">${t('kpiTokens')}</div><div class="k-val">${fmtTok(tot.tokens)}</div>
|
||||||
<div class="k-sub">${t('thPrompt')} ${fmtTok(tot.prompt_tokens)} · ${t('thCompl')} ${fmtTok(tot.completion_tokens)}</div></div>
|
<div class="k-sub">${t('thPrompt')} ${fmtTok(tot.prompt_tokens)} · ${t('thCompl')} ${fmtTok(tot.completion_tokens)}</div></div>
|
||||||
@ -748,7 +755,9 @@ async function paintStats() {
|
|||||||
paintStatusTable(st.by_status || []);
|
paintStatusTable(st.by_status || []);
|
||||||
paintKeyTable(st.by_key || [], st.key_names || {});
|
paintKeyTable(st.by_key || [], st.key_names || {});
|
||||||
paintRecords(st.records || [], st.key_names || {});
|
paintRecords(st.records || [], st.key_names || {});
|
||||||
renderKeySelect((st.by_key || []).map(k => k.name));
|
const rex = $('#rec-exit');
|
||||||
|
if (rex) rex.innerHTML = statsKeyF ? `<button class="ghost small" onclick="renderKeyF('')">${t('exitKeyView')} ${esc(st.key_names && st.key_names[statsKeyF] ? st.key_names[statsKeyF] : statsKeyF)}</button>` : '';
|
||||||
|
renderKeySelect((st.by_key || []).map(k => k.name), st.key_names || {});
|
||||||
} catch (e) { console.error('[stats]', e); }
|
} catch (e) { console.error('[stats]', e); }
|
||||||
}
|
}
|
||||||
function paintModelTable(rows) {
|
function paintModelTable(rows) {
|
||||||
|
|||||||
@ -92,7 +92,7 @@ func (tn *TierNode) NextStart() int64 {
|
|||||||
// requests (rotation state resets when the chain is rebuilt, e.g. after
|
// requests (rotation state resets when the chain is rebuilt, e.g. after
|
||||||
// editing the rules — acceptable, the swap also resets cooldowns).
|
// editing the rules — acceptable, the swap also resets cooldowns).
|
||||||
type Chain struct {
|
type Chain struct {
|
||||||
Tiers []*TierNode // descending tier order
|
Tiers []*TierNode // ascending tier order (tier 1 = highest priority, tried first)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TierErrors is the per-tier failure summary carried by ChainErr. Errors
|
// TierErrors is the per-tier failure summary carried by ChainErr. Errors
|
||||||
@ -133,10 +133,10 @@ func (e *ChainErr) Error() string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildChain groups rules into descending tiers and resolves each slot's
|
// BuildChain groups rules into ascending tiers (tier 1 = highest priority,
|
||||||
// provider via prov. Rules whose provider resolves to nil are dropped (the
|
// tried first) and resolves each slot's provider via prov. Rules whose
|
||||||
// source no longer serves the model). Slot order within a tier follows the
|
// provider resolves to nil are dropped (the source no longer serves the
|
||||||
// configured rule order.
|
// model). Slot order within a tier follows the configured rule order.
|
||||||
func BuildChain(rules []Rule, prov func(model, source string) Provider) *Chain {
|
func BuildChain(rules []Rule, prov func(model, source string) Provider) *Chain {
|
||||||
byTier := map[int][]*Slot{}
|
byTier := map[int][]*Slot{}
|
||||||
var tiers []int
|
var tiers []int
|
||||||
@ -157,7 +157,7 @@ func BuildChain(rules []Rule, prov func(model, source string) Provider) *Chain {
|
|||||||
Prov: p,
|
Prov: p,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(tiers, func(i, j int) bool { return tiers[i] > tiers[j] })
|
sort.Slice(tiers, func(i, j int) bool { return tiers[i] < tiers[j] })
|
||||||
ch := &Chain{}
|
ch := &Chain{}
|
||||||
for _, t := range tiers {
|
for _, t := range tiers {
|
||||||
tn := &TierNode{Tier: t, Slots: byTier[t]}
|
tn := &TierNode{Tier: t, Slots: byTier[t]}
|
||||||
@ -221,12 +221,12 @@ func runTier(ctx context.Context, tn *TierNode, cands []*Slot, base int64, req *
|
|||||||
return tierResult{hard: hard}
|
return tierResult{hard: hard}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chainDrive runs a request down the chain (plan 2.3): tiers descending,
|
// chainDrive runs a request down the chain (plan 2.3): tiers ascending (tier
|
||||||
// per-tier round-robin starting at the tier cursor, same-tier runs ordered by
|
// 1, the highest priority, first), per-tier round-robin starting at the tier
|
||||||
// preference (negative prefs sink but stay reachable). Quota-exhausted and
|
// cursor, same-tier runs ordered by preference (negative prefs sink but stay
|
||||||
// cooling slots are filtered up front; a fully busy tier is polled for a
|
// reachable). Quota-exhausted and cooling slots are filtered up front; a
|
||||||
// bounded time before falling through. Failures are summarized in *ChainErr
|
// fully busy tier is polled for a bounded time before falling through.
|
||||||
// for the caller to map to HTTP 503.
|
// 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) {
|
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 {
|
if chain == nil || len(chain.Tiers) == 0 {
|
||||||
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
||||||
|
|||||||
@ -97,12 +97,12 @@ func TestBuildChainTierOrdering(t *testing.T) {
|
|||||||
if len(ch.Tiers) != 3 {
|
if len(ch.Tiers) != 3 {
|
||||||
t.Fatalf("tiers = %d, want 3", len(ch.Tiers))
|
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 {
|
if ch.Tiers[0].Tier != 1 || ch.Tiers[1].Tier != 2 || ch.Tiers[2].Tier != 3 {
|
||||||
t.Fatalf("tier order = %d,%d,%d, want 3,2,1", ch.Tiers[0].Tier, ch.Tiers[1].Tier, ch.Tiers[2].Tier)
|
t.Fatalf("tier order = %d,%d,%d, want 1,2,3", ch.Tiers[0].Tier, ch.Tiers[1].Tier, ch.Tiers[2].Tier)
|
||||||
}
|
}
|
||||||
// same-tier slots keep configured order, unresolvable rule is dropped
|
// 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" {
|
if len(ch.Tiers[0].Slots) != 2 || ch.Tiers[0].Slots[0].Model != "a" || ch.Tiers[0].Slots[1].Model != "d" {
|
||||||
t.Fatalf("tier 1 slots = %+v", ch.Tiers[2].Slots)
|
t.Fatalf("tier 1 slots = %+v", ch.Tiers[0].Slots)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,9 +182,9 @@ func TestChainAllBusyBoundedWaitThenNextTier(t *testing.T) {
|
|||||||
a.busy.Store(true)
|
a.busy.Store(true)
|
||||||
b.busy.Store(true)
|
b.busy.Store(true)
|
||||||
ch := BuildChain([]Rule{
|
ch := BuildChain([]Rule{
|
||||||
{Tier: 5, Model: "a", Source: "s1"},
|
{Tier: 1, Model: "a", Source: "s1"},
|
||||||
{Tier: 5, Model: "b", Source: "s2"},
|
{Tier: 1, Model: "b", Source: "s2"},
|
||||||
{Tier: 4, Model: "c", Source: "s3"},
|
{Tier: 2, Model: "c", Source: "s3"},
|
||||||
}, bySource(a, b, c))
|
}, bySource(a, b, c))
|
||||||
s := New(0)
|
s := New(0)
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
|
|||||||
Reference in New Issue
Block a user