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

@ -208,6 +208,19 @@ func (p *Provider) ModelByID(id string) *config.Model {
return nil
}
// ModelIDFold returns the configured model id matching id case-insensitively
// ("" when no model matches). AUTO-chain slot models are normalized through
// this so cooldown state, quota windows and the upstream model id all refer
// to the exact configured spelling.
func (p *Provider) ModelIDFold(id string) string {
for _, m := range p.cfg.Models {
if strings.EqualFold(m.ID, id) {
return m.ID
}
}
return ""
}
// ModelFor resolves the model name this provider should send upstream.
// If the requested model is not owned by this provider (e.g. an AUTO chain
// fallback), it returns this provider's highest-priority chat model instead.
@ -239,6 +252,26 @@ func (p *Provider) bestChatModel() string {
return bestID
}
// bestImageModel returns the highest-priority image-kind model of this source
// (fallback: first configured model). Used for AUTO image generation so a
// mixed source never sends a chat model to /v1/images/generations.
func (p *Provider) bestImageModel() string {
bestID, bestPrio := "", -1
for _, m := range p.cfg.Models {
if m.Kind != "" && m.Kind != "image" {
continue
}
if m.Priority > bestPrio {
bestPrio = m.Priority
bestID = m.ID
}
}
if bestID == "" && len(p.cfg.Models) > 0 {
bestID = p.cfg.Models[0].ID
}
return bestID
}
// IsAutoID reports whether s is an AUTO routing placeholder.
func isAutoID(s string) bool {
s = strings.TrimSpace(s)
@ -609,7 +642,11 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
}
raw, status, err := p.do(ctx, p.URL(), body, hdrs)
if err != nil {
p.RecordFailure(model, 0)
// a client disconnect or cancelled context is neither a success nor
// a failure for scheduling purposes — only upstream errors count
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, err
}
if status != 200 {
@ -618,10 +655,19 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
}
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
if err != nil {
// adapter produced unusable output: a real failure the slot must
// back off from, otherwise a broken adapter source is retried at
// full latency forever
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, err
}
var out types.UnifiedResponse
if err := json.Unmarshal([]byte(unified), &out); err != nil {
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unified)
}
p.RecordSuccess(model)
@ -664,7 +710,11 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
ch := make(chan types.UnifiedChunk, 64)
sel := <-rc
if sel.err != nil {
p.RecordFailure(model, 0)
// client disconnect/cancel before the first byte: not a scheduling
// failure (a healthy source must not be cooled by client cancellations)
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
p.Release()
return nil, sel.err
}
@ -681,6 +731,8 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
defer sel.resp.Body.Close()
scanner := bufio.NewScanner(sel.resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var chunks int
var doneSeen bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
@ -691,6 +743,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
continue
}
if data == "[DONE]" {
doneSeen = true
select {
case ch <- types.UnifiedChunk{Done: true}:
case <-ctx.Done():
@ -711,6 +764,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue
}
chunks++
select {
case ch <- ck:
case <-ctx.Done():
@ -720,9 +774,15 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
// The stream ended cleanly ([DONE] seen or EOF without an upstream
// read error): record success so a previously cooled model can be
// retried. A client disconnect or mid-stream read error is neither
// success nor failure for scheduling purposes.
// success nor failure for scheduling purposes. A 200 that produced
// zero chunks and no [DONE] is an empty stream, i.e. a failure
// before the first chunk — record it so the slot can fall back.
if ctx.Err() == nil && scanner.Err() == nil {
p.RecordSuccess(model)
if doneSeen || chunks > 0 {
p.RecordSuccess(model)
} else {
p.RecordFailure(model, 0)
}
}
}()
return ch, nil
@ -735,7 +795,15 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
return nil, err
}
defer p.Release()
model := p.ModelFor(req.Model)
// AUTO/unknown model: resolve to this source's image model, never to the
// best chat model (a mixed source would otherwise send a chat id to
// /v1/images/generations). An explicitly pinned model is honored as-is.
if isAutoID(req.Model) || p.ModelByID(req.Model) == nil {
r := *req
r.Model = p.bestImageModel()
req = &r
}
model := req.Model
b, _ := json.Marshal(req)
transformed, err := p.vm.Transform(p.adapter+"_image", "transform_request", string(b))
@ -749,7 +817,10 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
}
raw, status, err := p.do(ctx, p.ImageURL(), transformed, hdrs)
if err != nil {
p.RecordFailure(model, 0)
// client disconnect/cancel: not a scheduling failure
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, err
}
if status != 200 {
@ -767,6 +838,9 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
}
var img types.ImageGenResponse
if err := json.Unmarshal([]byte(raw), &img); err != nil {
if ctx.Err() == nil {
p.RecordFailure(model, 0)
}
return nil, fmt.Errorf("unmarshal image response: %w", err)
}
out.ImageData = img.Data