feat: opencode zen adapter + first-run config generation, fix stats/stream bugs

- adapters/opencode.lua: opencode.ai zen free pool adapter — sends the
  opencode client User-Agent (zen fingerprints clients by UA; non-official
  clients hit FreeUsageLimitError); pairs with api_key: public
- config: no config file ships in the repo; first run generates a default
  config at the -config path with a random admin key, loopback listen and a
  keyless zen source (config.EnsureDefault); remove config.example.yaml
- lua: seed bundled adapters from the embedded FS instead of a hardcoded
  name list
- ui: widen model kind select (chat was clipped to 'cha')
- phase 5 bugfixes: stats ms/s bucket mixing, cleanScopes nil, ctx.Err
  guards, direct-path ModelAvailable, empty stream body failure,
  bestImageModel rewrite, transform failure recording, Core.mu, timer,
  effective model for tool-calls
This commit is contained in:
JianFeeeee
2026-08-13 12:25:07 +08:00
parent d06210204b
commit 2bc1d0e67a
22 changed files with 910 additions and 148 deletions

View File

@ -100,7 +100,10 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid
return nil, effective
}
first := cands[0]
eff := first.ModelFor(model)
// use the prefix-stripped id (effective), never the raw "src:model" form:
// ModelFor does exact matching and would fall back to the source's best
// chat model for an unknown id
eff := first.ModelFor(effective)
if eff == "" {
eff = firstModel(first)
}

View File

@ -210,20 +210,25 @@ func (s *Stats) aggregateLocked(r Req) {
}
incStatus(a, strconv.Itoa(r.Status), r)
}
// window bucket for quota enforcement (per source-model pair, per unix hour)
// window bucket for quota enforcement (per source-model pair, per unix
// hour). r.Time is unix MILLISECONDS (audit format); hourSec is seconds,
// so convert before bucketing — otherwise the bucket width would be
// 3.6s and every WindowTokens cutoff comparison would be off by ~1000x.
tok := r.Prompt + r.Compl
if tok > 0 && r.Model != "" {
key := r.Model
if r.Source != "" {
key = r.Source + "::" + r.Model
}
h := r.Time / hourSec
h := (r.Time / 1000) / hourSec
hm := s.modelHour[key]
if hm == nil {
hm = map[int64]int64{}
s.modelHour[key] = hm
}
hm[h] += tok
// retention: 24*40 = 960 hourly buckets ≈ 40 days of history (covers
// the longest "month" quota window)
if len(hm) > 24*40 {
for k := range hm {
if k < h-24*40 {
@ -324,10 +329,10 @@ func AutoPeriodSeconds(period string, hours int64) int64 {
return 0
}
// WindowTokens returns the tokens billed for the model within the last `sec`
// seconds (0 = since forever).
// WindowTokens returns the tokens consumed for one model (optionally pinned
// to a single source) within the window; sec <= 0 means all time.
// to a single source) within the window; sec <= 0 means all time. Buckets are
// whole unix hours, so a sliding window overcounts by up to one hour — an
// accepted truncation for quota enforcement.
func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
s.mu.Lock()
defer s.mu.Unlock()

View File

@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestStatsByStatus(t *testing.T) {
@ -91,18 +92,21 @@ func TestAuditRotationRecords(t *testing.T) {
func TestLoadAuditFullReplay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
// timestamps relative to now: buckets are whole unix hours, so window
// assertions must not depend on the minute-of-hour of the test run.
now := time.Now()
lines := []string{
`{"obj":"access","time":1699999999000,"key":"k","method":"GET","path":"/api/stats","status":200}`,
`{"time":1700000000000,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":100,"completion_tokens":50,"latency_ms":10,"ok":true,"status":200}`,
fmt.Sprintf(`{"time":%d,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":100,"completion_tokens":50,"latency_ms":10,"ok":true,"status":200}`, now.Add(-65*time.Minute).UnixMilli()),
`{this is not valid json`,
`{"time":1700003600000,"key":"k","type":"stream","model":"m","source":"s","prompt_tokens":200,"completion_tokens":20,"latency_ms":20,"ok":false,"status":503}`,
fmt.Sprintf(`{"time":%d,"key":"k","type":"stream","model":"m","source":"s","prompt_tokens":200,"completion_tokens":20,"latency_ms":20,"ok":false,"status":503}`, now.Add(-30*time.Minute).UnixMilli()),
"garbage-not-json\n",
`{"time":1700007200000,"key":"k","type":"chat","model":"m2","source":"s","prompt_tokens":7,"completion_tokens":3,"latency_ms":5,"ok":true,"status":200}`,
fmt.Sprintf(`{"time":%d,"key":"k","type":"chat","model":"m2","source":"s","prompt_tokens":7,"completion_tokens":3,"latency_ms":5,"ok":true,"status":200}`, now.Add(-30*time.Minute).UnixMilli()),
}
loaded := strings.Join(lines, "\n") + "\n" + strings.Repeat("x", 1<<18) + "\n"
// oversized row at the END proves the scanner tolerates >64KB lines and
// still finishes the replay instead of truncating silently.
loaded += `{"time":1700010800000,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":1,"ok":true,"status":200}` + "\n"
loaded += fmt.Sprintf(`{"time":%d,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":1,"ok":true,"status":200}`, now.Add(-time.Minute).UnixMilli()) + "\n"
if err := os.WriteFile(path, []byte(loaded), 0644); err != nil {
t.Fatal(err)
}
@ -121,9 +125,24 @@ func TestLoadAuditFullReplay(t *testing.T) {
if len(s.recs) != 4 {
t.Fatalf("ring must hold only real requests, got %d rows: %#v", len(s.recs), s.recs)
}
// quota window rebuilt from full history
if w := s.WindowTokens("m", "s", hourSec); w != 372 {
t.Fatalf("window tokens want 372, got %d", w)
// quota window rebuilt from full history. All-time and multi-hour windows
// must see everything; a 1h window must NOT return all records (the old
// ms/seconds unit bug made any sec>0 window return everything), and must
// always include the row written one minute ago (current hour bucket).
if w := s.WindowTokens("m", "s", 0); w != 372 {
t.Fatalf("all-time window want 372, got %d", w)
}
if w := s.WindowTokens("m", "s", 3*hourSec); w != 372 {
t.Fatalf("3h window want 372, got %d", w)
}
if w := s.WindowTokens("m", "s", 24*hourSec); w != 372 {
t.Fatalf("24h window want 372, got %d", w)
}
if w := s.WindowTokens("m", "s", hourSec); w < 2 || w >= 372 {
t.Fatalf("1h window want [2,372), got %d", w)
}
if w := s.WindowTokens("m2", "s", 24*hourSec); w != 10 {
t.Fatalf("m2 24h window want 10, got %d", w)
}
// by_status only from requests (200 x3, 503 x1) — access line must not count
if st := s.byStatus[200]; st == nil || st.Reqs != 3 {

View File

@ -205,7 +205,7 @@ label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px}
.row{display:flex;gap:12px}.row>div{flex:1}
.model-row{display:flex;gap:6px;align-items:center;width:100%}
.model-row .m-id{flex:1;min-width:0;width:0}
.model-row .m-kind{flex:0 0 70px;width:70px}
.model-row .m-kind{flex:0 0 96px;width:96px}
.model-row .del{flex:0 0 auto;padding:4px 8px}
.muted{color:var(--muted)}
.hidden,.hidden#tab-chat{display:none}