mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user