From 839f33ba0179f4e6f3101cb8549d79717a2dff4d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 10:33:03 +0800 Subject: [PATCH] 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) --- internal/config/config.go | 1 + internal/config/config_test.go | 79 +++++++++++++++++++- internal/config/secret.go | 132 +++++++++++++++++++++++++++++++++ internal/config/store.go | 75 ++++++++++++++++++- internal/core/core.go | 18 ++++- internal/gateway/ui/index.html | 70 ++++++++--------- internal/provider/provider.go | 84 +++++++++++++++++---- internal/provider/registry.go | 2 +- 8 files changed, 405 insertions(+), 56 deletions(-) create mode 100644 internal/config/secret.go diff --git a/internal/config/config.go b/internal/config/config.go index 7be1c2d..726a1e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 069545e..36bb769 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } } \ No newline at end of file diff --git a/internal/config/secret.go b/internal/config/secret.go new file mode 100644 index 0000000..e6db159 --- /dev/null +++ b/internal/config/secret.go @@ -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 /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:". +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 +} \ No newline at end of file diff --git a/internal/config/store.go b/internal/config/store.go index 4bb4ee9..c251ca6 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -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 { diff --git a/internal/core/core.go b/internal/core/core.go index f736f94..5275f4a 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -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)) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 1260b57..d94bc84 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -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 .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 => `${esc(x.name)}${esc(x.adapter)} - ${x.models.map(m => `${esc(m)}`).join('')} +
${x.models.map(m => `${esc(m)}`).join('')}
${esc(x.base_url || '')} ${x.live_available ? `${t('online')}` : `${t('offline')}`} ${x.max_concurrent}`).join(''); diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 565a743..f785e8f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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 /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 /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 } diff --git a/internal/provider/registry.go b/internal/provider/registry.go index ed381ed..9d7c106 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -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)