Files
ModelRouter/internal/config/config.go
root 7941b4110a refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持
按维护者要求(issue #1 回复方向):内核不处理任何 billing 特有逻辑;插件有完整
OS 权限,自己保存配置文件。据此不再保留任何"内核 ↔ billing 插件"的专用对接。

## 内核(删除)
- `internal/billing`(Go 编译器 + 测试):删除;
- `internal/core` 的 billing 接口/降级应用/规则加载:删除(含 `billingPath`);
- `internal/gateway` 的 `/api/plugins/billing/rules` 端点与测试:删除;
- `config` 的 `BillingDSL/Profile/Rule/Token/Peak/LongContext` 与校验、
  `billing:`/`billing_file:` 配置段:删除。系统配置里不再有 billing 段。
内核只保留所有插件通用的能力:加载 .lua、插件状态持久化、插件目录
(`__llmsproxy_plugin_dir`)、`plugin.http` 路由、插件可用的 io/os。

## 插件(billing.lua)
- 规则文件移到**插件自己的目录**:`<plugin_dir>/billing.json`,由插件用 io 自己
  读写(tmp + rename 原子替换);加载插件时自己读文件并编译;
- 规则以**源名** `source` 为键(`url:"*"` 仅表示全局模型价目表):URL→源名映射
  属于内核配置,插件不为它和内核对接;编辑页在浏览器里取 `/api/sources` 选源名;
- 规则编辑 API 改为插件自己的 `/api/plugins/http/billing/rules`
  (GET/PUT/POST/DELETE,roles=admin),写盘也在插件内完成;
- 计费账目仍在插件状态文件(内核通用持久化)`<plugin_dir>/.billing.state.json`;
- `priceFor` 落地 profile 级 `default` 策略与显式 `mode=unpriced` 的优先级。

## 迁移
生产已迁移:`config.yaml` 的 `billing:` 规则转为 `plugins/billing.json`(按源名
重写),并从系统配置中移除;规则文件与系统配置从此各自独立保存。

## 判据
- `internal/lua/billing_rules_test.go`:插件自写 billing.json 并重启恢复、
  free/subscription/token/通配符编译语义、1e6+1e6 token = 0.75 端到端、
  非法规则被拒且不覆盖旧文件、reasoning/long_context/peak;
- `internal/lua/billing_test.go`:profile default 策略三态 + 显式 unpriced 优先;
- `internal/lua/billing_ui_test.go`:规则编辑页仍挂在 billing 页内、列宽/列数守卫。
2026-10-20 18:57:23 +08:00

584 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package config loads the gateway YAML configuration plus a runtime overlay
// (web UI edits) and resolves them into sources with per-model priority.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Config is the top-level gateway configuration.
type Config struct {
Path string `yaml:"-" json:"-"`
Listen string `yaml:"listen"`
GatewayKeys []string `yaml:"gateway_keys"`
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
AdapterDir string `yaml:"adapter_dir"`
PluginDir string `yaml:"plugin_dir,omitempty"` // Lua plugin directory (optional; hooks + WebUI extensions)
RuntimeFile string `yaml:"runtime_file"`
TLSCertFile string `yaml:"tls_cert_file,omitempty"` // PEM cert; when set together with tls_key_file, serve HTTPS
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request
Sources []Source `yaml:"sources"`
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
// PluginVisibility overrides the audience a plugin declared for itself,
// keyed by plugin name: "admin" hides a plugin (and its state) from user
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
// explicitly. An absent name leaves the plugin's own declaration in force.
//
// This exists because a plugin author cannot know the operator's policy, and
// the operator should not have to edit a bundled .lua to change one — the
// next upgrade overwrites it.
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
// box seals credentials (sources' api_key/headers, keys' key) at rest.
// In-memory values are always plaintext; only the bytes on disk are sealed.
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
// filesystem-free.
box *SecretBox
}
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
const (
DefaultSourceTimeout = 120 * time.Second
DefaultSourceQueueTimeout = 60 * time.Second
DefaultSourceConcurrency = 8
)
// Model is a single exposed model id bound to a source.
//
// ★ 曾经有 `Priority int` 字段,并号称"高者优先参与 AUTO 选源"。**那是错的**:
//
// AUTO 链由 `config.ModelScope.Tier` 驱动(见 scheduler.Chain),而 priority
// 只被 `bestChatModel/bestImageModel` 用于"本源码内部该发哪个模型",
// 与跨源调度毫无关系。把它当成调度依据会让人误以为改个数字就能改顺序,
// 改完发现毫无效果 —— 已彻底删除,避免再次误导。
type Model struct {
ID string `yaml:"id" json:"id"`
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
}
// Source describes a single upstream LLM provider.
type Source struct {
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
// StreamEndpoint overrides the CHAT endpoint for streaming requests. Most
// upstreams stream on the same path; Gemini is the exception (same path,
// different verb). Declaring it here keeps that knowledge out of the kernel,
// which only compares opaque strings.
StreamEndpoint string `yaml:"endpoint_stream" json:"endpoint_stream,omitempty"`
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
// upstreams behind geo/IP blocks that only respond through a proxy.
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
// answer a business-level rejection with a status code the kernel would
// otherwise surface to the client — e.g. a per-account concurrency cap that
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
// the matcher is pure DATA (status list + response-body substrings), so no
// provider name ever appears in core.
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
Timeout time.Duration `yaml:"timeout" json:"-"`
MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"`
QueueTimeout time.Duration `yaml:"queue_timeout" json:"-"`
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
}
// RetryPolicy describes when a failed upstream attempt may be retried BEFORE
// anything is returned to the client. All conditions are data:
//
// OnStatus - retry only when the HTTP status is one of these
// OnBodyContains - ... AND the response body contains one of these substrings
// (empty = status alone decides)
// Delay - wait between attempts ("10s" etc.); parsed on demand
// MaxAttempts - TOTAL attempts including the first (1 or 0 = no retry)
//
// Scope: the retry happens inside one gateway request, so a stream is only
// retried before its first chunk reaches the client (the kernel already holds
// the first chunk back), and a non-stream call is retried as a whole.
type RetryPolicy struct {
OnStatus []int `yaml:"on_status,omitempty" json:"on_status,omitempty"`
OnBodyContains []string `yaml:"on_body_contains,omitempty" json:"on_body_contains,omitempty"`
Delay string `yaml:"delay,omitempty" json:"delay,omitempty"`
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
}
// ShouldRetry reports whether one failed attempt matches this policy.
func (r *RetryPolicy) ShouldRetry(status int, body string) bool {
if r == nil || r.MaxAttempts <= 1 {
return false
}
statusOK := len(r.OnStatus) == 0
for _, s := range r.OnStatus {
if s == status {
statusOK = true
break
}
}
if !statusOK {
return false
}
if len(r.OnBodyContains) == 0 {
return true
}
for _, sub := range r.OnBodyContains {
if sub != "" && strings.Contains(body, sub) {
return true
}
}
return false
}
// DelayDuration is the configured pause between attempts (default 10s).
func (r *RetryPolicy) DelayDuration() time.Duration {
if r == nil || r.Delay == "" {
return 10 * time.Second
}
d, err := time.ParseDuration(r.Delay)
if err != nil || d < 0 {
return 10 * time.Second
}
return d
}
// 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
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
cfg.Path = path
if err := cfg.ApplyDefaults(); err != nil {
return nil, err
}
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 and a fresh random admin key. No upstream sources are preconfigured
// (the repo ships none — add them via WebUI or config.yaml). 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"),
PluginDir: filepath.Join(abs, "plugins"),
RuntimeFile: filepath.Join(abs, "runtime.json"),
}
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.
func RemoveSourceFromYAML(path, name string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return err
}
content := doc.Content
if len(content) == 0 {
return nil
}
root := content[0]
if root.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(root.Content); i += 2 {
key, val := root.Content[i], root.Content[i+1]
if key.Value != "sources" || val.Kind != yaml.SequenceNode {
continue
}
kept := val.Content[:0]
for _, item := range val.Content {
if item.Kind != yaml.MappingNode {
continue
}
found := false
for j := 0; j+1 < len(item.Content); j += 2 {
if item.Content[j].Value == "name" && item.Content[j+1].Value == name {
found = true
break
}
}
if !found {
kept = append(kept, item)
}
}
val.Content = kept
}
out, err := yaml.Marshal(&doc)
if err != nil {
return err
}
return os.WriteFile(path, out, 0644)
}
// UpsertSourceInYAML adds or updates a source entry in the YAML config file.
// Uses yaml.Node to preserve the rest of the file's comments and formatting.
// When box is non-nil the source's credentials are sealed before writing, so a
// newly added source never lands in the file as plaintext.
func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error {
if path == "" {
return fmt.Errorf("config path is empty")
}
if err := sealSource(&src, box); err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return err
}
content := doc.Content
if len(content) == 0 {
return nil
}
root := content[0]
if root.Kind != yaml.MappingNode {
return nil
}
// Build yaml.Node for the new source value
srcRaw, _ := yaml.Marshal(src)
var srcNode yaml.Node
yaml.Unmarshal(srcRaw, &srcNode)
for i := 0; i+1 < len(root.Content); i += 2 {
key, val := root.Content[i], root.Content[i+1]
if key.Value != "sources" || val.Kind != yaml.SequenceNode {
continue
}
replaced := false
for _, item := range val.Content {
if item.Kind != yaml.MappingNode {
continue
}
for j := 0; j+1 < len(item.Content); j += 2 {
if item.Content[j].Value == "name" && item.Content[j+1].Value == name {
// Replace the existing entry with the new source
item.Content = srcNode.Content[0].Content
replaced = true
break
}
}
if replaced {
break
}
}
if !replaced {
val.Content = append(val.Content, srcNode.Content[0])
}
break
}
out, err := yaml.Marshal(&doc)
if err != nil {
return err
}
return os.WriteFile(path, out, 0644)
}
// Save writes the current config back to the YAML file (preserving comments
// via yaml.Node round-trip when possible, or full marshaling as fallback).
// Credentials are sealed on the way out and the in-memory copy is restored to
// plaintext afterwards, so callers keep working with usable values.
func (c *Config) Save() error {
if c.Path == "" {
return fmt.Errorf("config path is empty")
}
if err := c.sealInPlace(c.box); err != nil {
return err
}
out, err := yaml.Marshal(c)
c.unsealAfterWrite(c.box)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
return os.WriteFile(c.Path, out, 0644)
}
// ApplyDefaults sets missing values and validates the config.
func (c *Config) ApplyDefaults() error {
if c.Listen == "" {
c.Listen = ":8080"
}
if c.AdapterDir == "" {
c.AdapterDir = "adapters"
}
// PluginDir is deliberately NOT defaulted: with no plugins a missing
// directory would be created and seeded on every start for nothing. An
// operator opts in by setting it (or by dropping files into it).
if c.RuntimeFile == "" {
c.RuntimeFile = "runtime.json"
}
if c.DefaultModel == "" {
c.DefaultModel = "AUTO"
}
seen := map[string]bool{}
for i := range c.Sources {
s := &c.Sources[i]
if s.Name == "" {
return fmt.Errorf("config: sources[%d] missing name", i)
}
if s.BaseURL == "" {
return fmt.Errorf("config: source %s missing base_url", s.Name)
}
if s.Adapter == "" {
s.Adapter = "openai"
}
if s.Timeout == 0 {
s.Timeout = DefaultSourceTimeout
}
if s.QueueTimeout == 0 {
s.QueueTimeout = DefaultSourceQueueTimeout
}
if s.MaxConcurrent == 0 {
s.MaxConcurrent = DefaultSourceConcurrency
}
if seen[s.Name] {
return fmt.Errorf("config: duplicate source name %q", s.Name)
}
seen[s.Name] = true
for j := range s.Models {
m := &s.Models[j]
if m.ID == "" {
return fmt.Errorf("config: source %s has a model without id", s.Name)
}
// Allow same model ID on multiple sources — disambiguation is via
// "source-model" / "source:model" / "source/model" pinning and
// the AUTO chain slots carry explicit (model, source) pairs.
}
}
return nil
}
// RuntimeConfig is the legacy runtime file format (kept for migration only).
type RuntimeConfig struct {
Sources []Source `json:"sources,omitempty"`
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
}
// SourceTemplate stores reusable source configuration (everything except
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
StreamEndpoint string `json:"endpoint_stream,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy.
ProxyURL string `json:"proxy_url,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
RPM int `json:"rpm,omitempty"`
}
// GWKey is a gateway API key persisted in the config file. Role is "admin"
// (full management) or "user" (sees only its own key); Models is the allowed
// model scope with per-model token quota (0 = unlimited).
type GWKey struct {
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
// Auto is this key's own AUTO scheduling chain. It overrides the global
// cfg.Auto for requests authenticated with this key; nil (or an empty
// list) means "inherit the global chain", so keys created before per-key
// chains existed keep working untouched.
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
}
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
// inheriting the global one. An explicitly empty list still counts as "no own
// chain" so a user can clear an override back to the global default.
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
// KeyQuota is retained only to carry a scope entry's caps through the admin
// API. Quotas are per model, never per key: there is deliberately no key-wide
// total, so exhausting one model's budget never blocks the others.
type KeyQuota struct {
TokenQuota int64 `json:"token_quota"`
ReqQuota int64 `json:"req_quota"`
Period string `json:"period"`
Hours int64 `json:"hours"`
}
// NormalizeRole defaults an empty role to "user", so a key can never end up in
// a state where no role means "neither admin nor user".
func NormalizeRole(role string) string {
if role == "admin" {
return "admin"
}
return "user"
}
// Validate rejects a quota configuration that could not work as written. A
// period is only meaningful when at least one cap is set, and a cap of zero
// means "unlimited" rather than "deny everything", so those are the only two
// things worth rejecting.
func (q KeyQuota) Validate() error {
if q.TokenQuota < 0 {
return fmt.Errorf("token_quota must be >= 0 (0 = unlimited)")
}
if q.ReqQuota < 0 {
return fmt.Errorf("req_quota must be >= 0 (0 = unlimited)")
}
if q.Hours < 0 {
return fmt.Errorf("hours must be >= 0")
}
if q.TokenQuota > 0 || q.ReqQuota > 0 {
if err := ValidatePeriod(q.Period, q.Hours); err != nil {
return err
}
}
return nil
}
// ValidatePeriod accepts the quota period vocabulary: "" (never resets),
// "hour", "week", "month", or "nhour" with hours >= 1. An unknown period is
// rejected rather than silently treated as "never resets", which would turn a
// typo into an all-time quota — the opposite of what the operator typed.
func ValidatePeriod(period string, hours int64) error {
switch period {
case "", "hour", "week", "month":
return nil
case "nhour":
if hours < 1 {
return fmt.Errorf("period %q needs hours >= 1", period)
}
return nil
}
return fmt.Errorf("period must be one of \"\", hour, week, month, nhour (got %q)", period)
}
// ModelScope is one allowed model for a key, or one AUTO scheduling slot.
// Its TokenQuota and ReqQuota cap THAT entry only, independently of every
// other entry on the same key: a model that runs out of budget stops being
// served while the key's other models keep working. TokenQuota 0 / ReqQuota 0
// = unlimited. Period "" = never resets; "hour"/"week"/"month" are fixed
// windows; "nhour" uses Hours.
type ModelScope struct {
Model string `yaml:"model" json:"model"`
Source string `yaml:"source,omitempty" json:"source,omitempty"` // optional: pin to one upstream source; "" = any source
Tier int `yaml:"tier,omitempty" json:"tier,omitempty"`
TokenQuota int64 `yaml:"token_quota" json:"token_quota"`
ReqQuota int64 `yaml:"req_quota,omitempty" json:"req_quota,omitempty"`
Period string `yaml:"period,omitempty" json:"period,omitempty"`
Hours int64 `yaml:"hours,omitempty" json:"hours,omitempty"`
}
// UnmarshalJSON accepts both the legacy "model-id" string form and the
// {"model":"...","token_quota":N} object form so old runtime files keep
// loading.
func (m *ModelScope) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
m.Model = s
return nil
}
var o struct {
Model string `json:"model"`
Source string `json:"source"`
Tier int `json:"tier"`
TokenQuota int64 `json:"token_quota"`
ReqQuota int64 `json:"req_quota"`
Period string `json:"period"`
Hours int64 `json:"hours"`
}
if err := json.Unmarshal(b, &o); err != nil {
return err
}
m.Model = o.Model
m.Source = o.Source
m.Tier = o.Tier
m.TokenQuota = o.TokenQuota
m.ReqQuota = o.ReqQuota
m.Period = o.Period
m.Hours = o.Hours
return nil
}