mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(secrets): encrypt sensitive store fields (AES-GCM, master.key 0600, api_key_env) + fast models-endpoint probing (fix zen backlog + source status) + UI cleanup (drop redundant parens labels, grid models 4/row)
This commit is contained in:
@ -36,6 +36,7 @@ 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
|
||||
|
||||
@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -95,7 +96,7 @@ func TestStoreUpsertRemove(t *testing.T) {
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", Models: []Model{{ID: "m"}}}); err != nil {
|
||||
if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", APIKey: "sk-a", Models: []Model{{ID: "m"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Upsert(Source{Name: "b", BaseURL: "http://b", Models: []Model{{ID: "m2"}}}); err != nil {
|
||||
@ -119,4 +120,80 @@ func TestStoreUpsertRemove(t *testing.T) {
|
||||
if len(s2.List()) != 1 {
|
||||
t.Fatalf("reloaded list = %d", len(s2.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSecretEncryption(t *testing.T) {
|
||||
t.Setenv("LLMS_PROXY_MASTER_KEY", "")
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runtime.json")
|
||||
s := NewStore(path)
|
||||
if s.box == nil {
|
||||
t.Fatal("expected secret box")
|
||||
}
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := map[string]string{"Authorization": "Bearer sk-hdr", "X-Custom": "plain"}
|
||||
if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", APIKey: "sk-secret-123", Headers: headers}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveKey(GWKey{Key: "gw-secret", Role: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// file on disk must not contain plaintext secrets
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, plain := range []string{"sk-secret-123", "Bearer sk-hdr", "gw-secret"} {
|
||||
if strings.Contains(string(raw), plain) {
|
||||
t.Fatalf("secret %q stored in plaintext on disk", plain)
|
||||
}
|
||||
}
|
||||
// in-memory stays plaintext after the writes
|
||||
src := s.List()[0]
|
||||
if src.APIKey != "sk-secret-123" {
|
||||
t.Fatalf("in-memory api_key = %q", src.APIKey)
|
||||
}
|
||||
if src.Headers["Authorization"] != "Bearer sk-hdr" {
|
||||
t.Fatal("in-memory header not plaintext")
|
||||
}
|
||||
// reload: decrypted back
|
||||
s2 := NewStore(path)
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s2.List()[0].APIKey != "sk-secret-123" {
|
||||
t.Fatalf("reloaded api_key = %q", s2.List()[0].APIKey)
|
||||
}
|
||||
if k, ok := s2.KeyByValue("gw-secret"); !ok || k.Role != "admin" {
|
||||
t.Fatalf("reloaded key lookup failed: %+v %v", k, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretBoxRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
box, err := NewSecretBox(filepath.Join(dir, "runtime.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := box.Encrypt("sk-abc-xyz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasPrefix(v, "sk-") || strings.Contains(v, "abc-xyz") {
|
||||
t.Fatalf("ciphertext leaked plaintext: %q", v)
|
||||
}
|
||||
out, err := box.Decrypt(v)
|
||||
if err != nil || out != "sk-abc-xyz" {
|
||||
t.Fatalf("roundtrip: %q %v", out, err)
|
||||
}
|
||||
if plain, err := box.Decrypt("sk-plain"); err != nil || plain != "sk-plain" {
|
||||
t.Fatalf("plain passthrough: %q %v", plain, err)
|
||||
}
|
||||
// wrong key must error
|
||||
bad, _ := NewSecretBox(filepath.Join(t.TempDir(), "runtime.json"))
|
||||
if _, err := bad.Decrypt(v); err == nil {
|
||||
t.Fatal("expected decrypt failure with wrong key")
|
||||
}
|
||||
}
|
||||
132
internal/config/secret.go
Normal file
132
internal/config/secret.go
Normal file
@ -0,0 +1,132 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var encPrefix = "enc:v1:"
|
||||
|
||||
// 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
|
||||
// (hex, 64 chars) or from a generated <runtime-dir>/master.key file.
|
||||
type SecretBox struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// NewSecretBox builds a box from the env var, falling back to a master.key
|
||||
// file next to the runtime file. If neither exists, a fresh random key is
|
||||
// generated and written to master.key with mode 0600.
|
||||
func NewSecretBox(runtimeFile string) (*SecretBox, error) {
|
||||
if v := os.Getenv("LLMS_PROXY_MASTER_KEY"); v != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(v))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("LLMS_PROXY_MASTER_KEY: expected 64 hex chars")
|
||||
}
|
||||
return newBox(key)
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(runtimeFile), "master.key")
|
||||
key, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
key = make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(hex.EncodeToString(key)), 0600); err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
log.Printf("[config] generated master key at %s (mode 0600, keep it safe)", path)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
key, err = hex.DecodeString(strings.TrimSpace(string(key)))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("master.key %s: expected 64 hex chars (regenerate if empty)", path)
|
||||
}
|
||||
}
|
||||
return newBox(key)
|
||||
}
|
||||
|
||||
func newBox(key []byte) (*SecretBox, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SecretBox{aead: aead}, nil
|
||||
}
|
||||
|
||||
// Encrypt returns "" for empty input, otherwise "enc:v1:<base64>".
|
||||
func (b *SecretBox) Encrypt(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := b.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return encPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// Decrypt reverses Encrypt. Plain values (no prefix) are returned unchanged
|
||||
// so old unencrypted files keep loading. Bad ciphertext is returned as-is
|
||||
// with an error, never silently rewritten.
|
||||
func (b *SecretBox) Decrypt(v string) (string, error) {
|
||||
if !strings.HasPrefix(v, encPrefix) {
|
||||
return v, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(v, encPrefix))
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
n := b.aead.NonceSize()
|
||||
if len(raw) < n {
|
||||
return v, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, sealed := raw[:n], raw[n:]
|
||||
plain, err := b.aead.Open(nil, nonce, sealed, nil)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("decrypt failed (master key changed?)")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// MustDecrypt is a convenience wrapper that logs and returns the input on
|
||||
// failure so a bad value never silently becomes empty.
|
||||
func (b *SecretBox) MustDecrypt(v string) string {
|
||||
out, err := b.Decrypt(v)
|
||||
if err != nil {
|
||||
head := v
|
||||
if len(head) > 8 {
|
||||
head = head[:8]
|
||||
}
|
||||
log.Printf("[config] secret decrypt failed for value %q…: %v", head, err)
|
||||
return v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolvedAPIKey resolves a source key from api_key_env (env var) or api_key.
|
||||
func resolvedAPIKey(src Source, box *SecretBox) (string, error) {
|
||||
if src.APIKeyEnv != "" {
|
||||
v := os.Getenv(src.APIKeyEnv)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("source %s: env %s is empty or unset", src.Name, src.APIKeyEnv)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return box.MustDecrypt(src.APIKey), nil
|
||||
}
|
||||
@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
@ -12,10 +13,25 @@ type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
data RuntimeConfig
|
||||
box *SecretBox
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: path}
|
||||
s := &Store{path: path}
|
||||
if box, err := NewSecretBox(path); err == nil {
|
||||
s.box = box
|
||||
} else {
|
||||
log.Printf("[config] secrets disabled: %v (sensitive values will be stored in plaintext)", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SecretBox exposes the box used for persist-time encryption of source
|
||||
// api_key / header values read from other places (e.g. YAML). May be nil.
|
||||
func (s *Store) SecretBox() *SecretBox {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.box
|
||||
}
|
||||
|
||||
// Load reads the runtime file (missing file = empty state).
|
||||
@ -30,7 +46,27 @@ func (s *Store) Load() error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, &s.data)
|
||||
if err := json.Unmarshal(data, &s.data); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.box != nil {
|
||||
s.decryptLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptLocked replaces persisted ciphertext fields with their plaintext in
|
||||
// memory (the runtime always works with plaintext; only the file is sealed).
|
||||
func (s *Store) decryptLocked() {
|
||||
for i := range s.data.Sources {
|
||||
s.data.Sources[i].APIKey = s.box.MustDecrypt(s.data.Sources[i].APIKey)
|
||||
for k, v := range s.data.Sources[i].Headers {
|
||||
s.data.Sources[i].Headers[k] = s.box.MustDecrypt(v)
|
||||
}
|
||||
}
|
||||
for i := range s.data.Keys {
|
||||
s.data.Keys[i].Key = s.box.MustDecrypt(s.data.Keys[i].Key)
|
||||
}
|
||||
}
|
||||
|
||||
// List returns the runtime sources (those edited via web UI).
|
||||
@ -115,11 +151,44 @@ func (s *Store) DeletedAdapters() map[string]bool {
|
||||
}
|
||||
|
||||
func (s *Store) persistLocked() error {
|
||||
if s.box != nil {
|
||||
s.encryptLocked()
|
||||
}
|
||||
b, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0644)
|
||||
if s.box != nil {
|
||||
s.decryptLocked()
|
||||
}
|
||||
if err := os.WriteFile(s.path, b, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptLocked seals sensitive fields for the write; decryptLocked runs
|
||||
// right after marshaling so in-memory data stays plaintext.
|
||||
func (s *Store) encryptLocked() {
|
||||
for i := range s.data.Sources {
|
||||
if v, err := s.box.Encrypt(s.data.Sources[i].APIKey); err == nil {
|
||||
s.data.Sources[i].APIKey = v
|
||||
}
|
||||
h := s.data.Sources[i].Headers
|
||||
for k, v := range h {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if e, err := s.box.Encrypt(v); err == nil {
|
||||
h[k] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range s.data.Keys {
|
||||
if v, err := s.box.Encrypt(s.data.Keys[i].Key); err == nil {
|
||||
s.data.Keys[i].Key = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(list []string, s string) bool {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
@ -259,12 +260,27 @@ func (c *Core) mergedSources() []config.Source {
|
||||
for _, n := range order {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, byName[n])
|
||||
out = append(out, c.resolveSourceKey(byName[n]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveSourceKey applies api_key_env (env var overrides inline api_key) and
|
||||
// decrypts an inline enc:v1: ciphertext (useful for YAML-sourced keys).
|
||||
func (c *Core) resolveSourceKey(s config.Source) config.Source {
|
||||
if s.APIKeyEnv != "" {
|
||||
if v := os.Getenv(s.APIKeyEnv); v != "" {
|
||||
s.APIKey = v
|
||||
}
|
||||
return s
|
||||
}
|
||||
if box := c.store.SecretBox(); box != nil && strings.HasPrefix(s.APIKey, "enc:v1:") {
|
||||
s.APIKey = box.MustDecrypt(s.APIKey)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Core) rebuildRegistry() error {
|
||||
srcs := c.mergedSources()
|
||||
providers := make([]*provider.Provider, 0, len(srcs))
|
||||
|
||||
@ -55,6 +55,8 @@ th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line);
|
||||
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
tr:last-child td { border-bottom:0; }
|
||||
.tag { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12px; margin:2px 3px 2px 0; font-weight:500; }
|
||||
.src-models { display:grid; grid-template-columns:repeat(4,auto); justify-content:start; gap:2px 6px; min-width:0; }
|
||||
.src-models .tag { margin:0; }
|
||||
.tag-green { background:var(--tag-ok); color:var(--ok); }
|
||||
.tag-red { background:var(--tag-err); color:var(--err); }
|
||||
.tag-blue { background:var(--tag-blue); color:var(--accent); }
|
||||
@ -424,48 +426,48 @@ const STR = {
|
||||
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
|
||||
navStatus:'状态', navChat:'对话', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
|
||||
keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。',
|
||||
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注(可选)', kCreateBtn:'创建',
|
||||
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注', kCreateBtn:'创建',
|
||||
kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除',
|
||||
kNewKey:'新密钥(请立即复制保存)', kNewOK:'密钥已创建', kScope:'配置模型范围', kScopeTitle:'配置模型范围 —— 拖拽排列、下拉添加、×删除、复制/粘贴到其它用户',
|
||||
kNewKey:'新密钥', kNewOK:'密钥已创建', kScope:'配置模型范围', kScopeTitle:'配置模型范围 —— 拖拽排列、下拉添加、×删除、复制/粘贴到其它用户',
|
||||
kScopeHint:'拖拽排序 · 下拉添加 · × 删除 · 复制/粘贴列表', kScopeSel:'选择模型…', kCopyList:'复制列表', kPasteList:'粘贴列表', kSaveScope:'保存',
|
||||
kScopeSaved:'模型范围已保存', kScopeEmpty:'(全部模型,不限)', kScopeAll:'(空 = 不限)',
|
||||
kScopeSaved:'模型范围已保存', kScopeEmpty:'全部模型', kScopeAll:'空即不限',
|
||||
kMeTitle:'我的密钥', kMeRole:'角色', kMeModels:'我可用的模型', kMeHint:'密钥不可在此新建或删除;需要变更请联系管理员。',
|
||||
kEmpty:'暂无其他密钥', kDelSelf:'不能删除当前登录所用密钥', kDelConfirm:'确定删除密钥 %s 吗?此后该密钥立即失效。',
|
||||
kAll:'不限',
|
||||
kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动',
|
||||
kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除',
|
||||
kFormTitle:'模型与 Token 配额', kModelB:'模型', kSrcHint:'同一模型多源时请选择具体来源', kAnySrc:'任意源', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限',
|
||||
kPeriodB:'重置周期', kPerNothing:'不限(永不过期)', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数',
|
||||
kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除(从链中移除)',
|
||||
kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'(暂无模型 —— 点击 + 添加)', kAddB:'添加模型',
|
||||
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级页面的 AUTO 链选择可用源;点击任一模型可生成固定到该模型的配置。',
|
||||
kPeriodB:'重置周期', kPerNothing:'不限', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数',
|
||||
kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除',
|
||||
kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'暂无模型', kAddB:'添加模型',
|
||||
connTitle:'连接配置', connHint:'模型名默认 AUTO,按优先级页面的 AUTO 链选择可用源;点击任一模型可生成固定到该模型的配置。',
|
||||
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
|
||||
srcTitle:'源状态', srcCount:'共 %d 个',
|
||||
tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发',
|
||||
tName:'名称', tAdapter:'适配器', tModels:'模型', tURL:'地址', tConn:'连接', tConc:'并发',
|
||||
online:'在线', offline:'退避 / 不可用',
|
||||
adTitle:'已加载适配器', tVersion:'版本',
|
||||
chatTitle:'Chat 测试', cModel:'模型', cMsg:'(Enter 发送,Shift+Enter 换行)', send:'发送', clear:'清空',
|
||||
chatTitle:'Chat 测试', cModel:'模型', cMsg:'Enter 发送,Shift+Enter 换行', send:'发送', clear:'清空',
|
||||
cThinking:'推理', cImg:'图片', cPick:'选择图片', cRemoveImg:'移除', cWelcome:'开始对话——选择模型,输入消息即可测试上游连接',
|
||||
cStream:'调用 /v1/chat/completions(流式 SSE)',
|
||||
cStream:'调用 /v1/chat/completions 流式 SSE',
|
||||
srcEmpty:'还没有配置任何源',
|
||||
srcAdd:'+ 新增源', srcEdit:'编辑', srcDel:'删除',
|
||||
adEmpty:'尚未加载适配器',
|
||||
uploadTitle:'上传 / 拖拽 Lua 适配器', dropHint:'拖拽 .lua 文件到此处,或点击选择文件',
|
||||
adName:'名称(保存为 <名称>.lua)', tbLua:'Lua 脚本(返回 adapter table)', uploadBtn:'上传并加载',
|
||||
adName:'名称', tbLua:'Lua 脚本', uploadBtn:'上传并加载',
|
||||
modalNew:'新增源', modalEdit:'编辑源',
|
||||
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
|
||||
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
|
||||
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点',
|
||||
mImgEp:'生图端点', mConc:'并发上限', mTemp:'温度',
|
||||
mModels:'模型列表', mAddModel:'+ 模型',
|
||||
mMeta:'Meta(透传给 build_headers 钩子,JSON)', mSave:'保存', mCancel:'取消',
|
||||
mMeta:'Meta', mSave:'保存', mCancel:'取消',
|
||||
toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载',
|
||||
toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s',
|
||||
toastUploaded:'适配器已加载', toastUpFail:'上传失败: %s', toastNeedAll:'需要适配器名称和脚本',
|
||||
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
|
||||
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
|
||||
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO(自动选择可用源)',
|
||||
sortTitle:'画布排序:拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型(kind=image)不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||||
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s · %s', connAuto:'# 模型名 AUTO',
|
||||
sortTitle:'拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
|
||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载',
|
||||
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
||||
@ -477,11 +479,11 @@ const STR = {
|
||||
tagline:'Unified LLM Gateway', logout:'Log out', langTo:'中',
|
||||
navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters', navSort:'Priority', navKeys:'Keys',
|
||||
keysTitle:'Key management', keysHint:'Admin keys can view and manage every key and configure each user key\u0027s allowed models; user keys only see themselves.',
|
||||
kCreate:'Create key', kName:'Name', kRole:'Role', kRoleAdmin:'Admin', kRoleUser:'User', kNote:'Note (optional)', kCreateBtn:'Create',
|
||||
kCreate:'Create key', kName:'Name', kRole:'Role', kRoleAdmin:'Admin', kRoleUser:'User', kNote:'Note', kCreateBtn:'Create',
|
||||
kKey:'Key', kModels:'Allowed models', kCreated:'Created', kActions:'Actions', kCopy:'Copy key', kDel:'Delete',
|
||||
kNewKey:'New key (copy & save it now)', kNewOK:'Key created', kScope:'Model scope', kScopeTitle:'Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users',
|
||||
kNewKey:'New key — copy & save it now', kNewOK:'Key created', kScope:'Model scope', kScopeTitle:'Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users',
|
||||
kScopeHint:'drag to order · add from dropdown · × to remove · copy/paste list', kScopeSel:'Pick a model…', kCopyList:'Copy list', kPasteList:'Paste list', kSaveScope:'Save',
|
||||
kScopeSaved:'Model scope saved', kScopeEmpty:'(all models, unlimited)', kScopeAll:'(empty = unlimited)',
|
||||
kScopeSaved:'Model scope saved', kScopeEmpty:'all models', kScopeAll:'empty = unlimited',
|
||||
kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys cannot be created or deleted here; contact an admin to change.',
|
||||
kEmpty:'No other keys', kDelSelf:'cannot delete the key you are logged in with', kDelConfirm:'Delete key "%s"? It will stop working immediately.',
|
||||
kAll:'All',
|
||||
@ -489,36 +491,36 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
||||
kCopyB:'Copy', kEditB:'Edit', kDelB:'Delete',
|
||||
kFormTitle:'Model & token quota', kModelB:'Model', kSrcHint:'pick a source when the same model exists on several sources', kAnySrc:'any source', kQuotaB:'Token quota', kQuotaHintB:'0 / empty = unlimited',
|
||||
kPeriodB:'Reset period', kPerNothing:'Never', kPerHour:'Every hour', kPerWeek:'Every week', kPerMonth:'Every month', kPerHours:'Every N hours', kPerNHint:'hours',
|
||||
kEditQ:'Edit quota', kClearQ:'Clear quota', kDupB:'Duplicate slot', kDelB2:'Delete (remove from chain)',
|
||||
kDupOK:'Copied', kMovOK:'Moved', kSaved:'Saved', kEmptyB:'(no models yet — click + to add)', kAddB:'Add model',
|
||||
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — follows the AUTO chain from the Priority page. Click a model to pin it.',
|
||||
kEditQ:'Edit quota', kClearQ:'Clear quota', kDupB:'Duplicate slot', kDelB2:'Delete',
|
||||
kDupOK:'Copied', kMovOK:'Moved', kSaved:'Saved', kEmptyB:'no models yet — click + to add', kAddB:'Add model',
|
||||
connTitle:'Connection config', connHint:'Model defaults to AUTO — follows the AUTO chain from the Priority page. Click a model to pin it.',
|
||||
copyCfg:'Copy config', copyEnv:'Copy as env vars',
|
||||
srcTitle:'Sources', srcCount:'%d total',
|
||||
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
|
||||
online:'online', offline:'backoff / down',
|
||||
adTitle:'Loaded adapters', tVersion:'Version',
|
||||
chatTitle:'Chat', cModel:'Model', cMsg:'(Enter to send, Shift+Enter for newline)', send:'Send', clear:'Clear',
|
||||
chatTitle:'Chat', cModel:'Model', cMsg:'Enter to send, Shift+Enter for newline', send:'Send', clear:'Clear',
|
||||
cThinking:'Thinking', cImg:'Image', cPick:'Pick image', cRemoveImg:'Remove', cWelcome:'Start a conversation — pick a model to test the upstream connection',
|
||||
cStream:'calls /v1/chat/completions (streaming SSE)',
|
||||
cStream:'calls /v1/chat/completions streaming SSE',
|
||||
srcEmpty:'No sources configured yet',
|
||||
srcAdd:'+ Add source', srcEdit:'Edit', srcDel:'Delete',
|
||||
adEmpty:'No adapters loaded',
|
||||
uploadTitle:'Upload / drag a Lua adapter', dropHint:'Drag a .lua file here, or click to choose',
|
||||
adName:'Name (saved as <name>.lua)', tbLua:'Lua script (returns adapter table)', uploadBtn:'Upload & load',
|
||||
adName:'Name', tbLua:'Lua script', uploadBtn:'Upload & load',
|
||||
modalNew:'Add source', modalEdit:'Edit source',
|
||||
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)',
|
||||
mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature',
|
||||
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint',
|
||||
mImgEp:'Image endpoint', mConc:'Max concurrency', mTemp:'Temperature',
|
||||
mModels:'Models', mAddModel:'+ model',
|
||||
mMeta:'Meta (passed to build_headers hook, JSON)', mSave:'Save', mCancel:'Cancel',
|
||||
mMeta:'Meta', mSave:'Save', mCancel:'Cancel',
|
||||
toastCopied:'Copied', toastCopyFail:'Copy failed', toastSaved:'Saved & hot-reloaded',
|
||||
toastEmpty:'Enter a message', toastBadJson:'Meta is not valid JSON', toastSaveFail:'Save failed: %s',
|
||||
toastUploaded:'Adapter loaded', toastUpFail:'Upload failed: %s', toastNeedAll:'Adapter name and code required',
|
||||
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
|
||||
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
|
||||
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" follows the Priority page AUTO chain',
|
||||
sortTitle:'Canvas sorting: drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier (or reorder within it), drop into the gap between rows = move up/down a tier. Image models (kind=image) stay out.', sortDragGrip:'grab the handle to drag',
|
||||
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'empty', aErr:'Request failed: %s',
|
||||
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s · %s', connAuto:'# Model "AUTO" follows the Priority page AUTO chain',
|
||||
sortTitle:'Drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier or reorder within it, drop into the gap between rows = move up/down a tier. Image models stay out.', sortDragGrip:'grab the handle to drag',
|
||||
sortSave:'Save order', sortReset:'Reset', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect',
|
||||
sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
|
||||
sortSource:'source', sortPrio:'priority %s', sortEmpty:'no models in this source',
|
||||
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
|
||||
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download',
|
||||
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
|
||||
@ -602,7 +604,7 @@ async function renderStatus() {
|
||||
const key = window._key = (s.gateway_keys && s.gateway_keys[0]) || '';
|
||||
const srcRows = s.sources.map(x =>
|
||||
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
||||
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
|
||||
<td><div class="src-models">${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</div></td>
|
||||
<td><span class="muted">${esc(x.base_url || '')}</span></td>
|
||||
<td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t('online')}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || '')}"><i class="net-dot"></i>${t('offline')}</span>`}</td>
|
||||
<td>${x.max_concurrent}</td></tr>`).join('');
|
||||
|
||||
@ -180,9 +180,54 @@ func (p *Provider) Available() bool {
|
||||
return p.health.available()
|
||||
}
|
||||
|
||||
// Probe performs a lightweight reachability + auth check against the source
|
||||
// using its best chat model (1-token). It records the result for Status().
|
||||
// Probe performs a lightweight reachability + auth check against the source.
|
||||
// It first tries GET <base>/models (fast, ~1s for OpenAI-compatible upstreams)
|
||||
// and only falls back to a 1-token chat call when that endpoint is
|
||||
// unavailable. It does NOT touch the health/backoff state so probing never
|
||||
// disables a source.
|
||||
func (p *Provider) Probe(ctx context.Context) (bool, string) {
|
||||
ok, msg := p.probeModels(ctx)
|
||||
if !ok && msg == "" {
|
||||
ok, msg = p.probeChat(ctx)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.lastProbe.ok = ok
|
||||
p.lastProbe.err = msg
|
||||
p.lastProbe.at = time.Now().Unix()
|
||||
p.mu.Unlock()
|
||||
return ok, msg
|
||||
}
|
||||
|
||||
// probeModels GETs <base>/models. Returns (true,…) when reachable, (false,
|
||||
// errortext) on an auth/permanent failure, and (false,"") when the endpoint
|
||||
// simply isn't available so the caller can fall back to a chat probe.
|
||||
func (p *Provider) probeModels(ctx context.Context) (bool, string) {
|
||||
u := strings.TrimRight(p.cfg.BaseURL, "/") + "/models"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
if hdrs, herr := p.buildHeaders("{}", u); herr == nil {
|
||||
req.Header = hdrs
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
switch {
|
||||
case resp.StatusCode == 200:
|
||||
return true, ""
|
||||
case resp.StatusCode == 404 || resp.StatusCode == 405:
|
||||
return false, ""
|
||||
default:
|
||||
return false, fmt.Sprintf("api error %d: %s", resp.StatusCode, truncate(string(raw), 300))
|
||||
}
|
||||
}
|
||||
|
||||
// probeChat sends a minimal single-token chat request to the chat endpoint.
|
||||
func (p *Provider) probeChat(ctx context.Context) (bool, string) {
|
||||
ok := false
|
||||
msg := ""
|
||||
model := p.bestChatModel()
|
||||
@ -194,25 +239,32 @@ func (p *Provider) Probe(ctx context.Context) (bool, string) {
|
||||
model = ms[0]
|
||||
}
|
||||
}
|
||||
if model != "" {
|
||||
_, err := p.Chat(ctx, &types.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
MaxTokens: 1,
|
||||
})
|
||||
if model == "" {
|
||||
msg = "no chat model configured"
|
||||
} else {
|
||||
probe := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]interface{}{{"role": "user", "content": "hi"}},
|
||||
"max_tokens": 1,
|
||||
}
|
||||
body, err := json.Marshal(probe)
|
||||
if err == nil {
|
||||
ok = true
|
||||
var hdr http.Header
|
||||
if hdrs, herr := p.buildHeaders(string(body), p.URL()); herr == nil {
|
||||
hdr = hdrs
|
||||
}
|
||||
raw, status, derr := p.do(ctx, p.URL(), string(body), hdr)
|
||||
if derr != nil {
|
||||
msg = derr.Error()
|
||||
} else if status == 200 {
|
||||
ok = true
|
||||
} else {
|
||||
msg = fmt.Sprintf("api error %d: %s", status, truncate(raw, 500))
|
||||
}
|
||||
} else {
|
||||
msg = err.Error()
|
||||
}
|
||||
} else {
|
||||
msg = "no chat model configured"
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.lastProbe.ok = ok
|
||||
p.lastProbe.err = msg
|
||||
p.lastProbe.at = time.Now().Unix()
|
||||
p.mu.Unlock()
|
||||
return ok, msg
|
||||
}
|
||||
|
||||
|
||||
@ -208,7 +208,7 @@ func (r *Registry) ProbeAll(ctx context.Context) {
|
||||
wg.Add(1)
|
||||
go func(p *Provider) {
|
||||
defer wg.Done()
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
p.Probe(probeCtx)
|
||||
}(p)
|
||||
|
||||
Reference in New Issue
Block a user