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

@ -298,3 +298,106 @@ func TestChatBusyFailsFast(t *testing.T) {
t.Fatalf("first chat: %v", err)
}
}
func eventually(t *testing.T, timeout time.Duration, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out: %s", msg)
}
// TestChatClientCancelNotRecorded: a client disconnect before the response is
// neither success nor failure — the (source, model) state must stay clean.
func TestChatClientCancelNotRecorded(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release // hold the upstream open; release at cleanup
fmt.Fprint(w, `{"choices":[{"message":{"content":"late"}}]}`)
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, err := p.Chat(ctx, &types.ChatRequest{
Model: "m",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
})
errCh <- err
}()
<-started
cancel() // client disconnects mid-request
if err := <-errCh; err == nil {
t.Fatal("cancelled request must return an error")
}
close(release)
eventually(t, 2*time.Second, func() bool {
_, fc, _ := p.ModelHealthInfo("m")
return fc == 0
}, "client cancel must not record a scheduling failure")
}
// TestChatStreamEmptyBodyNotSuccess: a 200 that yields zero chunks and no
// [DONE] is a failure before the first chunk — the slot must back off.
func TestChatStreamEmptyBodyNotSuccess(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
// 200 with an empty body: no chunks, no [DONE]
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
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)
}
got := 0
for range ch {
got++
}
if got != 0 {
t.Fatalf("want empty stream, got %d chunks", got)
}
eventually(t, 2*time.Second, func() bool {
_, fc, _ := p.ModelHealthInfo("m")
return fc >= 1
}, "empty stream must record a failure")
}
// TestImageAutoUsesImageModel: AUTO image generation on a mixed source must
// send the image-kind model id, never the best chat model.
func TestImageAutoUsesImageModel(t *testing.T) {
var gotModel string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
gotModel, _ = body["model"].(string)
fmt.Fprint(w, `{"created":1,"data":[{"url":"http://x/1.png"}]}`)
}))
defer up.Close()
s := config.Source{Name: "mix", BaseURL: up.URL, Adapter: "openai", MaxConcurrent: 4}
s.Models = []config.Model{
{ID: "chat-m", Kind: "chat", Priority: 100},
{ID: "img-m", Kind: "image", Priority: 50},
}
p := newTestProvider(t, s)
resp, err := p.Image(context.Background(), &types.ImageGenRequest{Prompt: "cat", Model: "AUTO"})
if err != nil {
t.Fatalf("image: %v", err)
}
if len(resp.ImageData) == 0 {
t.Fatal("no image data returned")
}
if gotModel != "img-m" {
t.Fatalf("AUTO image must use the image-kind model, got %q", gotModel)
}
}