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:
root
2026-08-10 10:33:03 +08:00
parent e0978db150
commit 839f33ba01
8 changed files with 405 additions and 56 deletions

View File

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

View File

@ -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 {
@ -120,3 +121,79 @@ func TestStoreUpsertRemove(t *testing.T) {
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
View 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
}

View File

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

View File

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

View File

@ -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:'名称(保存为 &lt;名称&gt;.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('');

View File

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

View File

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