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

@ -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.

View File

@ -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"}}},

View File

@ -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