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

135
internal/core/core_test.go Normal file
View File

@ -0,0 +1,135 @@
package core
import (
"path/filepath"
"sync"
"testing"
"llmsproxy/internal/config"
)
// newTestConfig returns an in-memory config backed by temp files so
// NewFromConfig (store load, key/auto seeding, registry build) is exercised
// like production.
func newTestConfig(t *testing.T) *config.Config {
t.Helper()
dir := t.TempDir()
return &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-gw-test"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
},
}
}
func newTestCore(t *testing.T, cfg *config.Config) *Core {
t.Helper()
c, err := NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
return c
}
func TestCleanScopesNilForEmpty(t *testing.T) {
if got := cleanScopes(nil); got != nil {
t.Fatalf("nil in must yield nil out, got %#v", got)
}
if got := cleanScopes([]config.ModelScope{{Model: ""}}); got != nil {
t.Fatalf("all-empty entries must yield nil, got %#v", got)
}
got := cleanScopes([]config.ModelScope{{Model: "m", Source: "undefined"}})
if len(got) != 1 || got[0].Source != "" || got[0].Model != "m" {
t.Fatalf("placeholder normalization wrong: %#v", got)
}
}
// TestCreateKeyWithoutScopeUnrestricted: a user key created without a model
// scope must be unrestricted in-process (nil), identical to what config.yaml
// omitempty produces after a restart — no 403 lock-out, no silent widening.
func TestCreateKeyWithoutScopeUnrestricted(t *testing.T) {
c := newTestCore(t, newTestConfig(t))
k, err := c.CreateKey("svc", "user", nil, "")
if err != nil {
t.Fatalf("create: %v", err)
}
rec, ok := c.FindKey(k.Key)
if !ok {
t.Fatal("key not found")
}
if rec.Models != nil {
t.Fatalf("user key without scope must be unrestricted, got %#v", rec.Models)
}
}
// TestUpdateKeyPreservesScopeWhenOmitted: PUT /api/keys without a models field
// must keep the existing scope; only an explicit [] clears it.
func TestUpdateKeyPreservesScopeWhenOmitted(t *testing.T) {
c := newTestCore(t, newTestConfig(t))
k, err := c.CreateKey("svc", "user", []config.ModelScope{{Model: "gpt-4o"}}, "")
if err != nil {
t.Fatalf("create: %v", err)
}
upd, err := c.UpdateKey(k.Key, "renamed", "", nil, "")
if err != nil {
t.Fatalf("update: %v", err)
}
if len(upd.Models) != 1 || upd.Models[0].Model != "gpt-4o" {
t.Fatalf("scope must survive an update without models, got %#v", upd.Models)
}
cleared, err := c.UpdateKey(k.Key, "", "", []config.ModelScope{}, "")
if err != nil {
t.Fatalf("clear: %v", err)
}
if cleared.Models != nil {
t.Fatalf("explicit empty list must clear the scope, got %#v", cleared.Models)
}
}
// TestSaveAutoRulesNormalizesModelCase: a rule whose model name differs only
// in case must be normalized to the exact configured spelling so cooldown,
// quota windows and the upstream model id all agree.
func TestSaveAutoRulesNormalizesModelCase(t *testing.T) {
c := newTestCore(t, newTestConfig(t))
if err := c.SaveAutoRules([]config.ModelScope{{Model: "GPT-4O", Source: "s1", Tier: 1}}); err != nil {
t.Fatalf("save: %v", err)
}
ch := c.AutoChain()
if ch == nil || len(ch.Tiers) == 0 || len(ch.Tiers[0].Slots) == 0 {
t.Fatalf("chain empty: %#v", ch)
}
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" {
t.Fatalf("slot model must be normalized to the config spelling, got %q", got)
}
if got := ch.Tiers[0].Slots[0].Source; got != "s1" {
t.Fatalf("slot source wrong: %q", got)
}
}
// TestConcurrentKeyMutation exercises the c.cfg.Keys lock under -race: key
// creation/lookup/deletion from many goroutines at once.
func TestConcurrentKeyMutation(t *testing.T) {
c := newTestCore(t, newTestConfig(t))
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
k, err := c.CreateKey("k", "user", nil, "")
if err == nil {
if rec, ok := c.FindKey(k.Key); ok && rec.Key != k.Key {
t.Errorf("wrong key returned")
}
c.UpdateKey(k.Key, "renamed", "", nil, "")
c.DeleteKey(k.Key)
}
}()
}
wg.Wait()
}