mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
插件 = plugin_dir 下的单个 .lua 文件,做两件事:挂请求流水线的钩子、在启动时
贡献 WebUI 界面(整页或往现有页面追加组件)。两者独立。
## 流水线 stage(三个)
request_start 已解析鉴权、未选源
routed 已选定 (source, model)、未发往上游
request_end 每请求恰好一次,带最终计量
request_end 挂在 gateway.writeRec——四条入口路径(直连/AUTO × 流式/非流式)的
唯一汇合点:既不漏(流式 token 只有流结束才知道)也不重。
## 计费插件(plugins/billing.lua,默认 seed,开箱可用)
源 / 模型 / 密钥三个维度定价。token 价优先级 keys > models > default;per_request
固定价是**叠加**的(生图模型可以既算 token 又收固定费)。单位是 USD/单 token,
即各家 provider 的公布口径。累计 total / by_source / by_model / by_key / by_day。
失败请求保留 token 费用、丢弃固定费(可经 count_failures 翻转)。
界面 = 一个独立页 + 状态页顶部一块总开销 tile。
## 一个明确的设计边界
计费插件**只报表,不执法**。网关自己的配额会计(stats.go,入口强制)才是限额
权威,插件不参与任何路由/配额决策。两套独立会计若对不上,比一套功能略少的
更糟。
## ★ 中途改掉的一个根本设计错误
最初让插件复用适配器的**弹性 worker 池**(多状态)。这对适配器是对的(它们无
状态),对插件是错的:计费插件往 plugin.state 累加,多状态意味着总量被劈成
几份;而 SetState 写价格只写进其中一个 worker,钩子恰好跑到另一个时**所有请求
按 0 计费**。改为**单状态 + 互斥锁**。代价写进文档:钩子必须短、同步、不阻塞,
卡住的钩子会卡住所有插件的钩子。
这个 bug 是测试逼出来的——先写了 SetState+Fire 的用例,数字全是 0 才挖出来。
另一个连带缺陷:只带 prices 的 PUT 会整体替换 state,把累计量清零。改为
prices/state 分离——prices 是配置、state 是历史,改价不动账。
## 撞到的三个 Lua 绑定的坑(都写进注释)
- SetGlobal **会 pop 栈**:连着调两次,第二次从空栈取,赋成 nil
- GetField 索引越界是 **SIGABRT 整个进程**,不是 panic,recover 救不了
- Call(nargs, n) **不接受函数索引**,它调的是 nargs 个参数正下方那个;
传索引会调到参数上("attempt to call a table value")
另外 GetField/SetField 用绝对索引,SetTop(0) 之后必须重取。
## 错误隔离
钩子 error() 不影响转发:捕获 → 记进 hook_errors → 跳下一个插件。适配器出错
会让源进冷却,插件出错**零惩罚**——插件是可选功能。/api/plugins 的 hook_errors
让"坏掉的插件"可见而不是静默消失。
## 界面注入
GET /api/ui-inject 一次返回所有插件的扩展(侧栏需要全部 page 才能建好)。
WebUI 在首次 render **之前** await 注入:先插 HTML 再重建 <script> 让它执行
(innerHTML/template 插入的 script 不会执行,这正是要的效果——避免脚本跑在
自己 DOM 之前)。注入失败不影响仪表盘。
browser 侧 pluginAPI 暴露 fetchState / postState / onTabShown。
## 文档
docs/plugins.md —— 快速上手、加载与热更新、三个 stage 的完整字段表、界面扩展、
状态与 HTTP API、运行时约束(单状态/异常隔离/内置函数)、计费插件的定价与
计费策略、排错表、与适配器的对比表。
## 判据(328 个测试全绿,插件相关 33 个)
- 计费断言的是**具体金额**(0.00625 / 0.0402 / 0.0075…),不是"能加载"
- 4 个变异都红:钩子异常不隔离 / prices 清空累计 / 忽略 key 优先级 /
毫秒时间戳不换算
- UI 侧 6 个判据把注入顺序、script 执行时机、pluginAPI 名称、tab 路由、
anchor 四种形式、失败非致命全钉住
- 鉴权:state 读任意角色、写仅 admin
489 lines
17 KiB
Go
489 lines
17 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"
|
||
"path/filepath"
|
||
"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 密钥页管理)
|
||
// 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, 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"`
|
||
// 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"`
|
||
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)
|
||
}
|
||
|
||
// 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"`
|
||
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"`
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|