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:
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@ -63,8 +64,13 @@ type Source struct {
|
||||
QueueTimeout time.Duration `yaml:"queue_timeout" json:"-"`
|
||||
}
|
||||
|
||||
// Load reads and validates a config file.
|
||||
// Load reads and validates a config file. When the file does not exist yet a
|
||||
// default config is generated at that path first (first-run bootstrap), so a
|
||||
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := EnsureDefault(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -80,6 +86,63 @@ func Load(path string) (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// EnsureDefault creates a default config file at path when it does not exist
|
||||
// yet and returns whether it was created. The repo ships no config file
|
||||
// (config files carry real keys); the binary generates one per install with a
|
||||
// fresh random admin key. An existing file is never touched.
|
||||
func EnsureDefault(path string) (bool, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
if err := writeDefaultConfig(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeDefaultConfig writes a minimal, safe-by-default config: loopback-only
|
||||
// listen, a fresh random admin key, and a no-key zen source that works out of
|
||||
// the box. adapter_dir / runtime_file live next to the config file so the
|
||||
// binary works regardless of the working directory it is started from.
|
||||
func writeDefaultConfig(path string) error {
|
||||
key, err := NewGatewayKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate gateway key: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
abs = dir
|
||||
}
|
||||
cfg := Config{
|
||||
Listen: "127.0.0.1:8080",
|
||||
GatewayKeys: []string{key},
|
||||
DefaultModel: "AUTO",
|
||||
AdapterDir: filepath.Join(abs, "adapters"),
|
||||
RuntimeFile: filepath.Join(abs, "runtime.json"),
|
||||
Sources: []Source{
|
||||
{
|
||||
Name: "zen",
|
||||
BaseURL: "https://opencode.ai/zen/v1",
|
||||
APIKey: "public", // zen 免费池:官方无 key 客户端实际发送 Bearer public
|
||||
Adapter: "opencode",
|
||||
Models: []Model{{ID: "deepseek-v4-flash-free", Priority: 100, Kind: "chat"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := yaml.Marshal(&cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal default config: %w", err)
|
||||
}
|
||||
// The config holds the plaintext admin key — restrict permissions.
|
||||
if err := os.MkdirAll(abs, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir config dir: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, out, 0600)
|
||||
}
|
||||
|
||||
// RemoveSourceFromYAML deletes the named source entry from the config file so
|
||||
// the delete is a real one (no tombstone needed). Uses yaml.Node to preserve
|
||||
// the rest of the file's comments and formatting.
|
||||
|
||||
@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -59,6 +60,89 @@ sources:
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultGeneratesOnMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nested", "config.yaml")
|
||||
created, err := EnsureDefault(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("expected creation for missing file")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || (runtime.GOOS != "windows" && fi.Mode().Perm() != 0600) {
|
||||
t.Fatalf("generated config must be 0600 (holds plaintext key), got %v", fi.Mode().Perm())
|
||||
}
|
||||
// generated file must load and be safe-by-default
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load generated: %v", err)
|
||||
}
|
||||
if len(cfg.GatewayKeys) != 1 || !strings.HasPrefix(cfg.GatewayKeys[0], "sk-gw-") {
|
||||
t.Fatalf("gateway keys = %v", cfg.GatewayKeys)
|
||||
}
|
||||
if strings.Contains(string(raw), cfg.GatewayKeys[0]) == false {
|
||||
t.Fatal("generated key must be written into the file")
|
||||
}
|
||||
if cfg.Listen != "127.0.0.1:8080" {
|
||||
t.Fatalf("listen = %q, want loopback-only", cfg.Listen)
|
||||
}
|
||||
if len(cfg.Sources) != 1 || cfg.Sources[0].Name != "zen" || cfg.Sources[0].Adapter != "opencode" {
|
||||
t.Fatalf("default sources = %+v", cfg.Sources)
|
||||
}
|
||||
if cfg.Sources[0].APIKey != "public" {
|
||||
t.Fatalf("zen api_key = %q", cfg.Sources[0].APIKey)
|
||||
}
|
||||
if cfg.Sources[0].Models[0].ID != "deepseek-v4-flash-free" {
|
||||
t.Fatalf("default model = %+v", cfg.Sources[0].Models)
|
||||
}
|
||||
// adapter_dir / runtime_file resolve next to the config file
|
||||
if !strings.HasPrefix(cfg.AdapterDir, dir) || !strings.HasPrefix(cfg.RuntimeFile, dir) {
|
||||
t.Fatalf("paths must live next to the config: adapter_dir=%s runtime_file=%s", cfg.AdapterDir, cfg.RuntimeFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultDoesNotOverwriteExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cfg.yaml")
|
||||
content := "listen: 127.0.0.1:9999\ngateway_keys: [sk-keep]\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := EnsureDefault(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("existing file must not be reported as created")
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if string(raw) != content {
|
||||
t.Fatalf("existing file was overwritten: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGatewayKeyIsRandom(t *testing.T) {
|
||||
a, err := NewGatewayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewGatewayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("two generated keys must differ")
|
||||
}
|
||||
if !strings.HasPrefix(a, "sk-gw-") || len(a) != len("sk-gw-")+32 {
|
||||
t.Fatalf("unexpected key format: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDefaultsDuplicateSource(t *testing.T) {
|
||||
cfg := Config{Sources: []Source{
|
||||
{Name: "a", BaseURL: "http://x", Models: []Model{{ID: "m1"}}},
|
||||
|
||||
@ -15,6 +15,16 @@ import (
|
||||
|
||||
var encPrefix = "enc:v1:"
|
||||
|
||||
// NewGatewayKey generates a fresh random gateway admin key with the
|
||||
// "sk-gw-" prefix, used when bootstrapping a default config on first run.
|
||||
func NewGatewayKey() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "sk-gw-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// SecretBox encrypts and decrypts sensitive values (upstream API keys,
|
||||
// custom header values, gateway keys) for persist-time protection. The
|
||||
// master key comes from the LLMS_PROXY_MASTER_KEY environment variable
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -20,8 +21,12 @@ import (
|
||||
"llmsproxy/internal/scheduler"
|
||||
)
|
||||
|
||||
// Core owns the running configuration and adapters.
|
||||
// Core owns the running configuration and adapters. mu guards every read and
|
||||
// write of c.cfg (keys / sources / auto rules) from the management API while
|
||||
// request paths look keys up concurrently; the AUTO chain itself is swapped
|
||||
// atomically and needs no lock.
|
||||
type Core struct {
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
vm *lua.VM
|
||||
store *config.Store
|
||||
@ -192,6 +197,8 @@ func (c *Core) PublicBaseURL() string { return c.cfg.PublicBaseURL }
|
||||
|
||||
// ListKeys returns all gateway keys (admin view).
|
||||
func (c *Core) ListKeys() []config.GWKey {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]config.GWKey, len(c.cfg.Keys))
|
||||
copy(out, c.cfg.Keys)
|
||||
return out
|
||||
@ -199,6 +206,8 @@ func (c *Core) ListKeys() []config.GWKey {
|
||||
|
||||
// FindKey looks up a gateway key record by its secret value.
|
||||
func (c *Core) FindKey(key string) (config.GWKey, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, k := range c.cfg.Keys {
|
||||
if k.Key == key {
|
||||
return k, true
|
||||
@ -209,6 +218,8 @@ func (c *Core) FindKey(key string) (config.GWKey, bool) {
|
||||
|
||||
// CreateKey builds a new random gateway key and persists it to config.yaml.
|
||||
func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
models = cleanScopes(models)
|
||||
key := make([]byte, 16)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
@ -234,6 +245,8 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str
|
||||
|
||||
// UpdateKey mutates a key's name/role/model scope and persists it.
|
||||
func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i, k := range c.cfg.Keys {
|
||||
if k.Key == key {
|
||||
if name != "" {
|
||||
@ -242,7 +255,11 @@ func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, not
|
||||
if role == "admin" || role == "user" {
|
||||
c.cfg.Keys[i].Role = role
|
||||
}
|
||||
c.cfg.Keys[i].Models = cleanScopes(models)
|
||||
// models == nil means the caller did not provide a scope (leave
|
||||
// the existing one untouched); an explicit [] clears it.
|
||||
if models != nil {
|
||||
c.cfg.Keys[i].Models = cleanScopes(models)
|
||||
}
|
||||
c.cfg.Keys[i].Note = note
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return config.GWKey{}, err
|
||||
@ -255,6 +272,8 @@ func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, not
|
||||
|
||||
// DeleteKey removes a key record; returns false if it did not exist.
|
||||
func (c *Core) DeleteKey(key string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i, k := range c.cfg.Keys {
|
||||
if k.Key == key {
|
||||
c.cfg.Keys = append(c.cfg.Keys[:i], c.cfg.Keys[i+1:]...)
|
||||
@ -268,13 +287,20 @@ func (c *Core) DeleteKey(key string) (bool, error) {
|
||||
|
||||
// AutoRules returns the AUTO scheduling slots in priority order.
|
||||
func (c *Core) AutoRules() []config.ModelScope {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]config.ModelScope, len(c.cfg.Auto))
|
||||
copy(out, c.cfg.Auto)
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanScopes drops empty model entries and normalizes placeholder source
|
||||
// names; it returns nil when no entries survive so an empty scope means
|
||||
// "unrestricted" (nil) instead of a restrictive-but-empty list — a non-nil
|
||||
// empty slice would 403 every model in-process yet become unrestricted again
|
||||
// after a restart (config omits empty models with omitempty).
|
||||
func cleanScopes(entries []config.ModelScope) []config.ModelScope {
|
||||
clean := make([]config.ModelScope, 0, len(entries))
|
||||
var clean []config.ModelScope
|
||||
for _, e := range entries {
|
||||
if e.Model == "" {
|
||||
continue
|
||||
@ -292,6 +318,8 @@ func cleanScopes(entries []config.ModelScope) []config.ModelScope {
|
||||
// kept, so a reliably good model keeps its edge while an edited chain applies
|
||||
// immediately.
|
||||
func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cfg.Auto = cleanScopes(entries)
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return err
|
||||
@ -396,7 +424,10 @@ func (c *Core) rebuildRegistry() error {
|
||||
}
|
||||
|
||||
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
|
||||
// against the current providers.
|
||||
// against the current providers. Slot model ids are normalized to the exact
|
||||
// configured spelling (case-insensitive match), otherwise ModelFor would fall
|
||||
// back to the source's best chat model and cooldown/quota bookkeeping would
|
||||
// key on a name that never matches.
|
||||
func (c *Core) buildAutoChain() {
|
||||
prov := func(model, source string) scheduler.Provider {
|
||||
p := c.registry.ProviderForSlot(model, source)
|
||||
@ -411,20 +442,26 @@ func (c *Core) buildAutoChain() {
|
||||
rules := c.cfg.Auto
|
||||
sr := make([]scheduler.Rule, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
r := scheduler.Rule{
|
||||
Model: e.Model,
|
||||
Source: e.Source,
|
||||
model, source := e.Model, e.Source
|
||||
if p := c.registry.ProviderForSlot(e.Model, e.Source); p != nil {
|
||||
if exact := p.ModelIDFold(e.Model); exact != "" {
|
||||
model = exact
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
|
||||
continue // image-kind models never join the chat AUTO chain
|
||||
}
|
||||
if source == "" {
|
||||
source = p.Name()
|
||||
}
|
||||
}
|
||||
sr = append(sr, scheduler.Rule{
|
||||
Model: model,
|
||||
Source: source,
|
||||
Tier: e.Tier,
|
||||
Quota: e.TokenQuota,
|
||||
Period: e.Period,
|
||||
Hours: e.Hours,
|
||||
}
|
||||
if r.Source == "" {
|
||||
if p := c.registry.ProviderForSlot(e.Model, ""); p != nil {
|
||||
r.Source = p.Name()
|
||||
}
|
||||
}
|
||||
sr = append(sr, r)
|
||||
})
|
||||
}
|
||||
c.autoChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
@ -469,6 +506,8 @@ func (c *Core) AutoSlotStates() []AutoSlotState {
|
||||
|
||||
// Reload re-reads the runtime store and rebuilds sources.
|
||||
func (c *Core) Reload() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.store.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -505,6 +544,8 @@ func (c *Core) RemoveAdapter(name string) error {
|
||||
// ---- source management (web UI) ----
|
||||
|
||||
func (c *Core) AddSource(src config.Source) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := normalizeSource(&src); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -527,6 +568,8 @@ func (c *Core) AddSource(src config.Source) error {
|
||||
}
|
||||
|
||||
func (c *Core) RemoveSource(name string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, s := range c.cfg.Sources {
|
||||
if s.Name == name {
|
||||
if err := config.RemoveSourceFromYAML(c.cfg.Path, name); err != nil {
|
||||
@ -552,7 +595,11 @@ func (c *Core) removeCfgSource(name string) []config.Source {
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Core) Sources() []config.Source { return c.mergedSources() }
|
||||
func (c *Core) Sources() []config.Source {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.mergedSources()
|
||||
}
|
||||
|
||||
func normalizeSource(s *config.Source) error {
|
||||
if s.Name == "" || s.BaseURL == "" {
|
||||
|
||||
135
internal/core/core_test.go
Normal file
135
internal/core/core_test.go
Normal file
@ -0,0 +1,135 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// newTestConfig returns an in-memory config backed by temp files so
|
||||
// NewFromConfig (store load, key/auto seeding, registry build) is exercised
|
||||
// like production.
|
||||
func newTestConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-gw-test"},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestCore(t *testing.T, cfg *config.Config) *Core {
|
||||
t.Helper()
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCleanScopesNilForEmpty(t *testing.T) {
|
||||
if got := cleanScopes(nil); got != nil {
|
||||
t.Fatalf("nil in must yield nil out, got %#v", got)
|
||||
}
|
||||
if got := cleanScopes([]config.ModelScope{{Model: ""}}); got != nil {
|
||||
t.Fatalf("all-empty entries must yield nil, got %#v", got)
|
||||
}
|
||||
got := cleanScopes([]config.ModelScope{{Model: "m", Source: "undefined"}})
|
||||
if len(got) != 1 || got[0].Source != "" || got[0].Model != "m" {
|
||||
t.Fatalf("placeholder normalization wrong: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateKeyWithoutScopeUnrestricted: a user key created without a model
|
||||
// scope must be unrestricted in-process (nil), identical to what config.yaml
|
||||
// omitempty produces after a restart — no 403 lock-out, no silent widening.
|
||||
func TestCreateKeyWithoutScopeUnrestricted(t *testing.T) {
|
||||
c := newTestCore(t, newTestConfig(t))
|
||||
k, err := c.CreateKey("svc", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
rec, ok := c.FindKey(k.Key)
|
||||
if !ok {
|
||||
t.Fatal("key not found")
|
||||
}
|
||||
if rec.Models != nil {
|
||||
t.Fatalf("user key without scope must be unrestricted, got %#v", rec.Models)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateKeyPreservesScopeWhenOmitted: PUT /api/keys without a models field
|
||||
// must keep the existing scope; only an explicit [] clears it.
|
||||
func TestUpdateKeyPreservesScopeWhenOmitted(t *testing.T) {
|
||||
c := newTestCore(t, newTestConfig(t))
|
||||
k, err := c.CreateKey("svc", "user", []config.ModelScope{{Model: "gpt-4o"}}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
upd, err := c.UpdateKey(k.Key, "renamed", "", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if len(upd.Models) != 1 || upd.Models[0].Model != "gpt-4o" {
|
||||
t.Fatalf("scope must survive an update without models, got %#v", upd.Models)
|
||||
}
|
||||
cleared, err := c.UpdateKey(k.Key, "", "", []config.ModelScope{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if cleared.Models != nil {
|
||||
t.Fatalf("explicit empty list must clear the scope, got %#v", cleared.Models)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveAutoRulesNormalizesModelCase: a rule whose model name differs only
|
||||
// in case must be normalized to the exact configured spelling so cooldown,
|
||||
// quota windows and the upstream model id all agree.
|
||||
func TestSaveAutoRulesNormalizesModelCase(t *testing.T) {
|
||||
c := newTestCore(t, newTestConfig(t))
|
||||
if err := c.SaveAutoRules([]config.ModelScope{{Model: "GPT-4O", Source: "s1", Tier: 1}}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
ch := c.AutoChain()
|
||||
if ch == nil || len(ch.Tiers) == 0 || len(ch.Tiers[0].Slots) == 0 {
|
||||
t.Fatalf("chain empty: %#v", ch)
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" {
|
||||
t.Fatalf("slot model must be normalized to the config spelling, got %q", got)
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Source; got != "s1" {
|
||||
t.Fatalf("slot source wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentKeyMutation exercises the c.cfg.Keys lock under -race: key
|
||||
// creation/lookup/deletion from many goroutines at once.
|
||||
func TestConcurrentKeyMutation(t *testing.T) {
|
||||
c := newTestCore(t, newTestConfig(t))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
k, err := c.CreateKey("k", "user", nil, "")
|
||||
if err == nil {
|
||||
if rec, ok := c.FindKey(k.Key); ok && rec.Key != k.Key {
|
||||
t.Errorf("wrong key returned")
|
||||
}
|
||||
c.UpdateKey(k.Key, "renamed", "", nil, "")
|
||||
c.DeleteKey(k.Key)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@ -100,7 +100,10 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid
|
||||
return nil, effective
|
||||
}
|
||||
first := cands[0]
|
||||
eff := first.ModelFor(model)
|
||||
// use the prefix-stripped id (effective), never the raw "src:model" form:
|
||||
// ModelFor does exact matching and would fall back to the source's best
|
||||
// chat model for an unknown id
|
||||
eff := first.ModelFor(effective)
|
||||
if eff == "" {
|
||||
eff = firstModel(first)
|
||||
}
|
||||
|
||||
@ -210,20 +210,25 @@ func (s *Stats) aggregateLocked(r Req) {
|
||||
}
|
||||
incStatus(a, strconv.Itoa(r.Status), r)
|
||||
}
|
||||
// window bucket for quota enforcement (per source-model pair, per unix hour)
|
||||
// window bucket for quota enforcement (per source-model pair, per unix
|
||||
// hour). r.Time is unix MILLISECONDS (audit format); hourSec is seconds,
|
||||
// so convert before bucketing — otherwise the bucket width would be
|
||||
// 3.6s and every WindowTokens cutoff comparison would be off by ~1000x.
|
||||
tok := r.Prompt + r.Compl
|
||||
if tok > 0 && r.Model != "" {
|
||||
key := r.Model
|
||||
if r.Source != "" {
|
||||
key = r.Source + "::" + r.Model
|
||||
}
|
||||
h := r.Time / hourSec
|
||||
h := (r.Time / 1000) / hourSec
|
||||
hm := s.modelHour[key]
|
||||
if hm == nil {
|
||||
hm = map[int64]int64{}
|
||||
s.modelHour[key] = hm
|
||||
}
|
||||
hm[h] += tok
|
||||
// retention: 24*40 = 960 hourly buckets ≈ 40 days of history (covers
|
||||
// the longest "month" quota window)
|
||||
if len(hm) > 24*40 {
|
||||
for k := range hm {
|
||||
if k < h-24*40 {
|
||||
@ -324,10 +329,10 @@ func AutoPeriodSeconds(period string, hours int64) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// WindowTokens returns the tokens billed for the model within the last `sec`
|
||||
// seconds (0 = since forever).
|
||||
// WindowTokens returns the tokens consumed for one model (optionally pinned
|
||||
// to a single source) within the window; sec <= 0 means all time.
|
||||
// to a single source) within the window; sec <= 0 means all time. Buckets are
|
||||
// whole unix hours, so a sliding window overcounts by up to one hour — an
|
||||
// accepted truncation for quota enforcement.
|
||||
func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStatsByStatus(t *testing.T) {
|
||||
@ -91,18 +92,21 @@ func TestAuditRotationRecords(t *testing.T) {
|
||||
func TestLoadAuditFullReplay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.jsonl")
|
||||
// timestamps relative to now: buckets are whole unix hours, so window
|
||||
// assertions must not depend on the minute-of-hour of the test run.
|
||||
now := time.Now()
|
||||
lines := []string{
|
||||
`{"obj":"access","time":1699999999000,"key":"k","method":"GET","path":"/api/stats","status":200}`,
|
||||
`{"time":1700000000000,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":100,"completion_tokens":50,"latency_ms":10,"ok":true,"status":200}`,
|
||||
fmt.Sprintf(`{"time":%d,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":100,"completion_tokens":50,"latency_ms":10,"ok":true,"status":200}`, now.Add(-65*time.Minute).UnixMilli()),
|
||||
`{this is not valid json`,
|
||||
`{"time":1700003600000,"key":"k","type":"stream","model":"m","source":"s","prompt_tokens":200,"completion_tokens":20,"latency_ms":20,"ok":false,"status":503}`,
|
||||
fmt.Sprintf(`{"time":%d,"key":"k","type":"stream","model":"m","source":"s","prompt_tokens":200,"completion_tokens":20,"latency_ms":20,"ok":false,"status":503}`, now.Add(-30*time.Minute).UnixMilli()),
|
||||
"garbage-not-json\n",
|
||||
`{"time":1700007200000,"key":"k","type":"chat","model":"m2","source":"s","prompt_tokens":7,"completion_tokens":3,"latency_ms":5,"ok":true,"status":200}`,
|
||||
fmt.Sprintf(`{"time":%d,"key":"k","type":"chat","model":"m2","source":"s","prompt_tokens":7,"completion_tokens":3,"latency_ms":5,"ok":true,"status":200}`, now.Add(-30*time.Minute).UnixMilli()),
|
||||
}
|
||||
loaded := strings.Join(lines, "\n") + "\n" + strings.Repeat("x", 1<<18) + "\n"
|
||||
// oversized row at the END proves the scanner tolerates >64KB lines and
|
||||
// still finishes the replay instead of truncating silently.
|
||||
loaded += `{"time":1700010800000,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":1,"ok":true,"status":200}` + "\n"
|
||||
loaded += fmt.Sprintf(`{"time":%d,"key":"k","type":"chat","model":"m","source":"s","prompt_tokens":1,"completion_tokens":1,"latency_ms":1,"ok":true,"status":200}`, now.Add(-time.Minute).UnixMilli()) + "\n"
|
||||
if err := os.WriteFile(path, []byte(loaded), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -121,9 +125,24 @@ func TestLoadAuditFullReplay(t *testing.T) {
|
||||
if len(s.recs) != 4 {
|
||||
t.Fatalf("ring must hold only real requests, got %d rows: %#v", len(s.recs), s.recs)
|
||||
}
|
||||
// quota window rebuilt from full history
|
||||
if w := s.WindowTokens("m", "s", hourSec); w != 372 {
|
||||
t.Fatalf("window tokens want 372, got %d", w)
|
||||
// quota window rebuilt from full history. All-time and multi-hour windows
|
||||
// must see everything; a 1h window must NOT return all records (the old
|
||||
// ms/seconds unit bug made any sec>0 window return everything), and must
|
||||
// always include the row written one minute ago (current hour bucket).
|
||||
if w := s.WindowTokens("m", "s", 0); w != 372 {
|
||||
t.Fatalf("all-time window want 372, got %d", w)
|
||||
}
|
||||
if w := s.WindowTokens("m", "s", 3*hourSec); w != 372 {
|
||||
t.Fatalf("3h window want 372, got %d", w)
|
||||
}
|
||||
if w := s.WindowTokens("m", "s", 24*hourSec); w != 372 {
|
||||
t.Fatalf("24h window want 372, got %d", w)
|
||||
}
|
||||
if w := s.WindowTokens("m", "s", hourSec); w < 2 || w >= 372 {
|
||||
t.Fatalf("1h window want [2,372), got %d", w)
|
||||
}
|
||||
if w := s.WindowTokens("m2", "s", 24*hourSec); w != 10 {
|
||||
t.Fatalf("m2 24h window want 10, got %d", w)
|
||||
}
|
||||
// by_status only from requests (200 x3, 503 x1) — access line must not count
|
||||
if st := s.byStatus[200]; st == nil || st.Reqs != 3 {
|
||||
|
||||
@ -205,7 +205,7 @@ label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px}
|
||||
.row{display:flex;gap:12px}.row>div{flex:1}
|
||||
.model-row{display:flex;gap:6px;align-items:center;width:100%}
|
||||
.model-row .m-id{flex:1;min-width:0;width:0}
|
||||
.model-row .m-kind{flex:0 0 70px;width:70px}
|
||||
.model-row .m-kind{flex:0 0 96px;width:96px}
|
||||
.model-row .del{flex:0 0 auto;padding:4px 8px}
|
||||
.muted{color:var(--muted)}
|
||||
.hidden,.hidden#tab-chat{display:none}
|
||||
|
||||
95
internal/lua/adapters/opencode.lua
Normal file
95
internal/lua/adapters/opencode.lua
Normal file
@ -0,0 +1,95 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencode"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
|
||||
-- opencode.ai zen 网关按 User-Agent 指纹识别官方客户端并把请求分到免费额度池;
|
||||
-- 非官方 UA(curl/Go 默认等)会被分到匿名池并触发 FreeUsageLimitError。
|
||||
-- 因此固定发送 opencode 客户端的 UA;配合源配置 api_key: "public"(官方无 key
|
||||
-- 客户端实际发送 Bearer public)即可走免费池。
|
||||
adapter.headers = {
|
||||
["User-Agent"] = "opencode/0.1.0",
|
||||
}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then return "" end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (fr ~= nil)
|
||||
}
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -302,13 +302,21 @@ func (v *VM) pool(name string) *adapterPool {
|
||||
}
|
||||
|
||||
func (v *VM) writeBundledAdapters() error {
|
||||
known := []string{"openai", "anthropic", "deepseek", "gemini", "github", "groq", "mistral", "ollama", "kimicode"}
|
||||
for _, name := range known {
|
||||
dst := filepath.Join(v.dir, name+".lua")
|
||||
// Derive the set from the embedded FS instead of a hardcoded name list so
|
||||
// adding an adapter never requires maintaining a second list.
|
||||
entries, err := bundledAdapters.ReadDir("adapters")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(v.dir, e.Name())
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
data, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
|
||||
data, err := bundledAdapters.ReadFile("adapters/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -28,13 +29,56 @@ func TestLoadBundledAdapters(t *testing.T) {
|
||||
for _, a := range adapters {
|
||||
names[a.Name] = true
|
||||
}
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} {
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode", "opencode"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing adapter %s (got %v)", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstRunSeedsEveryBundledAdapter(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "adapters")
|
||||
vm := NewVM(dir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
// every embedded .lua must have been copied out (and no extra, non-.lua files)
|
||||
embedded, err := bundledAdapters.ReadDir("adapters")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeded := 0
|
||||
for _, e := range embedded {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||
continue
|
||||
}
|
||||
seeded++
|
||||
if _, err := os.Stat(filepath.Join(dir, e.Name())); err != nil {
|
||||
t.Errorf("embedded adapter %s not seeded", e.Name())
|
||||
}
|
||||
}
|
||||
if seeded == 0 {
|
||||
t.Fatal("no embedded adapters found")
|
||||
}
|
||||
written := 0
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
|
||||
written++
|
||||
}
|
||||
}
|
||||
if written != seeded {
|
||||
t.Fatalf("seeded %d files but %d embedded adapters exist", written, seeded)
|
||||
}
|
||||
// re-start with the existing dir: authoritative, never rewritten
|
||||
vm2 := NewVM(dir)
|
||||
if err := vm2.Start(); err != nil {
|
||||
t.Fatalf("second start: %v", err)
|
||||
}
|
||||
defer vm2.Stop()
|
||||
}
|
||||
|
||||
func TestTransformRequest(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
@ -65,6 +109,32 @@ func TestBuildHeadersFallbackStatic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeAdapterFingerprint(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
hdrs, err := vm.BuildHeaders("opencode", map[string]interface{}{"api_key": "public"})
|
||||
if err != nil {
|
||||
t.Fatalf("build headers: %v", err)
|
||||
}
|
||||
if ua := hdrs["User-Agent"]; ua != "opencode/0.1.0" {
|
||||
t.Fatalf("opencode adapter must send the opencode client UA, got %q", ua)
|
||||
}
|
||||
if hdrs["Authorization"] != "" {
|
||||
t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"])
|
||||
}
|
||||
// passthrough behaves like the openai adapter
|
||||
out, err := vm.Transform("opencode", "transform_request", `{"model":"x","disable_thinking":true,"extra_body":{},"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "disable_thinking") || strings.Contains(out, "extra_body") {
|
||||
t.Fatalf("opencode transform_request must strip provider fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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