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

@ -266,11 +266,13 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
}
// every candidate was busy or cooling: bounded poll before downgrading
deadline := time.Now().Add(busyWait)
timer := time.NewTimer(busyPoll)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return nil, nil, "", "", ctx.Err()
case <-time.After(busyPoll):
case <-timer.C:
}
done := time.Now().After(deadline)
if done {
@ -299,6 +301,7 @@ func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.Cha
ce.Tiers = append(ce.Tiers, res.hard...)
break // hard failure while waiting: stop waiting, fall through
}
timer.Reset(busyPoll)
}
}
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
@ -331,6 +334,10 @@ func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *type
// fallback switches the model id per provider instead of reusing the first
// candidate's model name. On success it returns the response together with
// the name of the provider and the exact model id that served the request.
// A candidate whose (source, model) pair is cooling down is skipped like a
// busy one — direct paths share the "cooldown is the only hard skip"
// semantics of the AUTO chain (plan 2.3/2.5); otherwise persistent direct
// traffic would keep renewing a capped auth cooldown forever.
func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatRequest) (*types.UnifiedResponse, string, string, error) {
attempts := s.MaxRetries + 1
var lastErr error
@ -338,6 +345,10 @@ func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatR
p := cands[i]
r := *req
r.Model = p.ModelFor(req.Model)
if !p.ModelAvailable(r.Model) {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
continue
}
resp, err := p.Chat(ctx, &r)
if ctx.Err() != nil {
return nil, "", "", ctx.Err()
@ -366,6 +377,10 @@ func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types
p := cands[i]
r := *req
r.Model = p.ModelFor(req.Model)
if !p.ModelAvailable(r.Model) {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
continue
}
resp, err := p.ChatStream(ctx, &r)
if err == nil {
return resp, p.Name(), r.Model, nil
@ -385,6 +400,10 @@ func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.Imag
var lastErr error
for i := 0; i < attempts && i < len(cands); i++ {
p := cands[i]
if !p.ModelAvailable(p.ModelFor(req.Model)) {
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), p.ModelFor(req.Model))
continue
}
resp, err := p.Image(ctx, req)
if err == nil {
return resp, p.Name(), nil

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