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

@ -67,6 +67,40 @@ func (f *fakeProvider) Image(ctx context.Context, req *types.ImageGenRequest) (*
return nil, errors.New("no image")
}
// TestDirectSkipsCooledCandidate: direct paths share the AUTO-chain rule that
// cooldown is the only hard skip — a cooling candidate must never be hit, and
// a fully cooling set must fail without touching upstream.
func TestDirectSkipsCooledCandidate(t *testing.T) {
hot := fakeProv("hot", "m")
cold := fakeProv("cold", "m")
cold.available.Store(false)
s := New(2)
req := &types.ChatRequest{
Model: "m",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
}
resp, src, _, err := s.Chat(context.Background(), []Provider{cold, hot}, req)
if err != nil || src != "hot" {
t.Fatalf("want hot to serve, got src=%q err=%v", src, err)
}
if resp == nil || resp.Content != "hot" {
t.Fatalf("bad response: %#v", resp)
}
if cold.chatHits.Load() != 0 {
t.Fatalf("cooled candidate must not be hit, got %d hits", cold.chatHits.Load())
}
// all candidates cooled: direct path reports it instead of hitting upstream
hot.available.Store(false)
hits := hot.chatHits.Load()
_, _, _, err = s.Chat(context.Background(), []Provider{cold, hot}, req)
if err == nil || !strings.Contains(err.Error(), "cooling down") {
t.Fatalf("want cooling-down error, got %v", err)
}
if hot.chatHits.Load() != hits || cold.chatHits.Load() != 0 {
t.Fatalf("cooled candidates must not be hit, got hot=%d cold=%d", hot.chatHits.Load(), cold.chatHits.Load())
}
}
// lookup resolves (model, source) -> provider for chain builders in tests.
type lookup func(m, s string) Provider