mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
- api.go: handleSourcesAPI now requires admin role (GET leaks upstream api_keys, POST/DELETE mutate routing)
- chat.go: hasScopeModel made a Gateway method that strips source-model/:// prefix strictly via Registry.EffectiveModel (only when the prefix names a real source serving the bare model) so dash-bearing ids like deepseek-v4-flash-free are never corrupted; +TestHasScopeModelWithSourcePrefix
- config.go: DefaultSourceTimeout/QueueTimeout/Concurrency constants shared by YAML ApplyDefaults and runtime sources
- core.go: mergedSources applies the same defaults to runtime sources (JSON never persisted timeout fields); a dead upstream can no longer hold a concurrency slot forever
- provider.go: split non-streaming client{Timeout} vs stream client{} sharing a Transport with ResponseHeaderTimeout, so long SSE bodies are not cut by client.Timeout; ChatStream uses doRawStream
- plan.md: mark P4-4/5/6 done, record P4-7/8 (tier-order, audit export, UI key view, zen upstream diagnosis)
- online verified: user key -> /api/sources 403 (GET+POST), admin 200, AUTO stream/non-stream healthy
248 lines
8.2 KiB
Go
248 lines
8.2 KiB
Go
// 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"
|
|
"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"`
|
|
RuntimeFile string `yaml:"runtime_file"`
|
|
MaxConcurrent int `yaml:"max_concurrent"` // global inflight cap, 0 = unlimited
|
|
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"`
|
|
}
|
|
|
|
// 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, with priority used by
|
|
// AUTO auto selection (higher number = preferred).
|
|
type Model struct {
|
|
ID string `yaml:"id" json:"id"`
|
|
Priority int `yaml:"priority" json:"priority"`
|
|
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
|
|
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"`
|
|
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:"-"`
|
|
}
|
|
|
|
// Load reads and validates a config file.
|
|
func Load(path string) (*Config, error) {
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
if c.RuntimeFile == "" {
|
|
c.RuntimeFile = "runtime.json"
|
|
}
|
|
if c.DefaultModel == "" {
|
|
c.DefaultModel = "AUTO"
|
|
}
|
|
seen := map[string]bool{}
|
|
modelOwners := map[string]string{}
|
|
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)
|
|
}
|
|
if owner, ok := modelOwners[m.ID]; ok {
|
|
return fmt.Errorf("config: model %q defined by both %s and %s", m.ID, owner, s.Name)
|
|
}
|
|
modelOwners[m.ID] = s.Name
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RuntimeConfig is the persisted web-UI editable slice (sources added/edited).
|
|
type RuntimeConfig struct {
|
|
Sources []Source `json:"sources"`
|
|
DeletedSources []string `json:"deleted_sources,omitempty"`
|
|
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
|
|
Keys []GWKey `json:"keys,omitempty"`
|
|
Auto []ModelScope `json:"auto,omitempty"`
|
|
}
|
|
|
|
// GWKey is a gateway API key persisted in the runtime store. 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 `json:"key"`
|
|
Role string `json:"role"`
|
|
Name string `json:"name,omitempty"`
|
|
Models []ModelScope `json:"models,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
CreatedAt int64 `json:"created_at,omitempty"`
|
|
Seed bool `json:"seed,omitempty"` // true if migrated from config gateway_keys
|
|
}
|
|
|
|
// ModelScope is one allowed model for a key, or one AUTO scheduling slot,
|
|
// with an optional token quota and reset period. TokenQuota 0 = unlimited;
|
|
// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour"
|
|
// uses Hours as the window length in hours.
|
|
type ModelScope struct {
|
|
Model string `json:"model"`
|
|
Source string `json:"source,omitempty"` // optional: pin to one upstream source; "" = any source
|
|
Tier int `json:"tier,omitempty"`
|
|
TokenQuota int64 `json:"token_quota"`
|
|
Period string `json:"period,omitempty"`
|
|
Hours int64 `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"`
|
|
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.Period = o.Period
|
|
m.Hours = o.Hours
|
|
return nil
|
|
}
|