mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 504c5ac9a0 | |||
| 70f1c879bd | |||
| 04e544c823 | |||
| 7e33d11d15 |
8
cmd/gui/package-lock.json
generated
8
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.5",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
@ -582,7 +582,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/webcrypto": {
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
|
||||
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
|
||||
"dev": true,
|
||||
@ -3335,7 +3335,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
|
||||
"dev": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.5",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -73,19 +73,26 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
gw, err := gateway.New(c, c.GatewayKeys())
|
||||
gw, err := gateway.New(c)
|
||||
if err != nil {
|
||||
log.Fatalf("[llmsproxy] gateway: %v", err)
|
||||
}
|
||||
|
||||
// Ops hygiene: surface the two most common footguns instead of silently
|
||||
// running with them.
|
||||
if keys := c.GatewayKeys(); len(keys) == 0 {
|
||||
log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected")
|
||||
//
|
||||
// Read the AUTHORITATIVE source. Auth uses core.ListKeys() (cfg.Keys), not
|
||||
// the legacy cfg.GatewayKeys list: seedKeys copies gateway_keys into keys[]
|
||||
// on first start and the YAML list is ignored for auth afterwards. Checking
|
||||
// GatewayKeys() here made the warning lie — once the legacy list was empty
|
||||
// (e.g. after rotating the starter key away) it reported "every request is
|
||||
// rejected" while seven working keys, one of them admin, were in service.
|
||||
if keys := c.ListKeys(); len(keys) == 0 {
|
||||
log.Printf("[llmsproxy] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys")
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
if k == "sk-gw-local-0001" || k == "sk-local-0001" {
|
||||
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k)
|
||||
if k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" {
|
||||
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
141
e2e/e2e_test.go
141
e2e/e2e_test.go
@ -326,3 +326,144 @@ func statusOf(resp *http.Response) int {
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// The startup warning about key configuration must describe the key set that
|
||||
// AUTH actually uses (cfg.Keys, via core.ListKeys), not the legacy
|
||||
// gateway_keys YAML list.
|
||||
//
|
||||
// It used to check GatewayKeys() only. seedKeys copies gateway_keys into keys[]
|
||||
// on first start and the YAML list stops being consulted, so once that list was
|
||||
// emptied — e.g. after rotating the starter key away — the process logged
|
||||
// "every request will be rejected" while seven working keys, one of them admin,
|
||||
// were in service. Observed in production: the warning appeared and that same
|
||||
// key returned HTTP 200 on /v1/models.
|
||||
//
|
||||
// The fixture reproduces exactly that shape: gateway_keys EMPTY, with the
|
||||
// working key present only in keys[].
|
||||
func TestStartupWarningReflectsRealKeysNotLegacyList(t *testing.T) {
|
||||
up := newMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
// Seed a real install once so keys[] gets populated from gateway_keys, then
|
||||
// rewrite the file to look like a rotated install: legacy list cleared, the
|
||||
// seeded key still present in keys[].
|
||||
path := writeConfig(t, dir, "127.0.0.1:0", map[string]*mockUpstream{"good": up})
|
||||
bin := buildBinary(t)
|
||||
|
||||
// First run creates the sealed config with keys[] populated.
|
||||
seedPort := freePort(t)
|
||||
cfg1 := writeConfig(t, dir, "127.0.0.1:"+seedPort, map[string]*mockUpstream{"good": up})
|
||||
g1 := startGateway(t, bin, "127.0.0.1:"+seedPort, cfg1)
|
||||
g1.stop()
|
||||
|
||||
data, err := os.ReadFile(cfg1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "keys:") {
|
||||
t.Fatalf("first run did not populate keys[]:\n%s", data)
|
||||
}
|
||||
|
||||
// Now clear the legacy gateway_keys list, keeping keys[] intact. The
|
||||
// rewritten file may use different indentation, so match loosely.
|
||||
cleared, n := clearGatewayKeys(string(data))
|
||||
if n == 0 {
|
||||
t.Fatalf("could not find a gateway_keys entry to clear in:\n%s", data)
|
||||
}
|
||||
if err := os.WriteFile(cfg1, []byte(cleared), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Start again and inspect the log. The config pins its listen address, so
|
||||
// rewrite that field to the port we will actually poll.
|
||||
port2 := freePort(t)
|
||||
rehosted, ok := replaceListen(string(cleared), "127.0.0.1:"+port2)
|
||||
if !ok {
|
||||
t.Fatalf("could not rewrite listen in:\n%s", cleared)
|
||||
}
|
||||
cfg2 := filepath.Join(dir, "config2.yaml")
|
||||
if err := os.WriteFile(cfg2, []byte(rehosted), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g2 := startGateway(t, bin, "127.0.0.1:"+port2, cfg2)
|
||||
defer g2.stop()
|
||||
|
||||
// Sanity: the key really does authenticate, so the warning would be a lie.
|
||||
resp, _ := g2.do("GET", "/v1/models", "", true)
|
||||
if resp == nil || resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("key from keys[] did not authenticate (status %v); test fixture is wrong",
|
||||
resp)
|
||||
}
|
||||
|
||||
logs := g2.logString()
|
||||
if strings.Contains(logs, "gateway_keys is EMPTY") {
|
||||
t.Errorf("startup logged 'gateway_keys is EMPTY ... every request is rejected' while "+
|
||||
"a working admin key was in service — the warning must read the authoritative "+
|
||||
"key set (core.ListKeys), not the legacy YAML list.\nlog:\n%s", logs)
|
||||
}
|
||||
_ = path
|
||||
_ = g1
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, port, _ := net.SplitHostPort(l.Addr().String())
|
||||
return port
|
||||
}
|
||||
|
||||
// stop terminates the gateway and waits, so its log buffer is complete.
|
||||
func (g *gatewayUnderTest) stop() {
|
||||
if g == nil || g.cmd == nil || g.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = g.cmd.Process.Kill()
|
||||
_, _ = g.cmd.Process.Wait()
|
||||
}
|
||||
|
||||
// logString returns everything the gateway has written so far.
|
||||
func (g *gatewayUnderTest) logString() string { return g.log.String() }
|
||||
|
||||
// clearGatewayKeys empties the gateway_keys list in a config, tolerating any
|
||||
// indentation the YAML writer chose. Returns the new text and how many entries
|
||||
// it removed.
|
||||
func clearGatewayKeys(src string) (string, int) {
|
||||
lines := strings.Split(src, "\n")
|
||||
var out []string
|
||||
removed := 0
|
||||
inList := false
|
||||
for _, l := range lines {
|
||||
trimmed := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, "gateway_keys:") {
|
||||
out = append(out, "gateway_keys: []")
|
||||
inList = true
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
if inList {
|
||||
if strings.HasPrefix(trimmed, "- ") {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
inList = false
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return strings.Join(out, "\n"), removed
|
||||
}
|
||||
|
||||
// replaceListen rewrites the top-level listen: value.
|
||||
func replaceListen(src, addr string) (string, bool) {
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "listen:") {
|
||||
lines[i] = "listen: " + addr
|
||||
return strings.Join(lines, "\n"), true
|
||||
}
|
||||
}
|
||||
return src, false
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c, []string{"sk-test"})
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ func adminGateway(t *testing.T, keys ...config.GWKey) *Gateway {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c, []string{"sk-admin"})
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ func quotaGateway(t *testing.T, keyA, keyB config.GWKey) (*Gateway, *upstreamCtr
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c, []string{keyA.Key, keyB.Key})
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
@ -323,7 +323,7 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway {
|
||||
for _, k := range keys {
|
||||
secrets = append(secrets, k.Key)
|
||||
}
|
||||
g, err := New(c, secrets)
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
@ -368,7 +368,7 @@ func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c, []string{"sk-a"})
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ func (g *Gateway) loginRecord(ip string, ok bool, now int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
func New(c *core.Core) (*Gateway, error) {
|
||||
sub, err := fs.Sub(uiFS, "ui")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
30
packaging/config.example.yaml
Normal file
30
packaging/config.example.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
# llmsproxy — 统一 OpenAI 兼容网关配置(示例模板)
|
||||
#
|
||||
# 这个是打包进发行包、供首次安装使用的模板。它故意不含任何可用的凭据:
|
||||
# 首次启动时程序会生成一个随机 admin key 并写入实际配置(WebUI「密钥」页可见)。
|
||||
#
|
||||
# ⚠ 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值
|
||||
# 都会成为一个全网皆知的 admin key(旧版示例里的 sk-gw-local-0001 就踩过
|
||||
# 这个坑:它被直接抄进生产配置并真实可用)。
|
||||
|
||||
# 网关监听地址(默认 :8080,建议绑内网/回环)
|
||||
listen: 127.0.0.1:8080
|
||||
|
||||
# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。
|
||||
gateway_keys: []
|
||||
|
||||
# 默认模型选择:具体模型 id 或 AUTO(按各源模型的 priority 自动选最高可用源)
|
||||
default_model: AUTO
|
||||
|
||||
# Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器)
|
||||
adapter_dir: adapters
|
||||
|
||||
# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效)
|
||||
runtime_file: runtime.json
|
||||
|
||||
# 全局并发上限(0 = 不限)
|
||||
max_concurrent: 0
|
||||
|
||||
# ---- 上游 LLM 源列表 ----
|
||||
# 留空:安装后到 WebUI 添加源,或从内置模板一键创建。
|
||||
sources: []
|
||||
@ -68,7 +68,12 @@ log "packaging tar.gz..."
|
||||
TAR_DIR="$DIST/llmsproxy-$VERSION-linux-amd64"
|
||||
rm -rf "$TAR_DIR"; mkdir -p "$TAR_DIR"
|
||||
cp "$BUILD_BIN" "$TAR_DIR/llmsproxy"
|
||||
cp "$ROOT/config.yaml" "$TAR_DIR/config.example.yaml"
|
||||
# Use the tracked sanitised template, NOT the local config.yaml: that file is
|
||||
# gitignored and carries the operator's real gateway_keys / upstream keys. It
|
||||
# was previously copied verbatim as config.example.yaml, which shipped a real,
|
||||
# working admin key (sk-gw-local-0001) to every install — and postinst.sh copies
|
||||
# the example to /etc when none exists, so it ended up live in production.
|
||||
cp "$ROOT/packaging/config.example.yaml" "$TAR_DIR/config.example.yaml"
|
||||
cp "$ROOT/packaging/llmsproxy.service" "$TAR_DIR/llmsproxy.service"
|
||||
mkdir -p "$TAR_DIR/adapters"
|
||||
cp "$ROOT"/internal/lua/adapters/*.lua "$TAR_DIR/adapters/"
|
||||
|
||||
@ -28,8 +28,11 @@ contents:
|
||||
dst: /etc/systemd/system/llmsproxy.service
|
||||
file_info:
|
||||
mode: 0644
|
||||
# example config (seeded to /etc/llmsproxy on first install by postinst)
|
||||
- src: ./config.yaml
|
||||
# example config (seeded to /etc/llmsproxy on first install by postinst).
|
||||
# Must be the tracked sanitised template: ./config.yaml is the operator's own
|
||||
# gitignored config and shipping it would leak real credentials — it shipped a
|
||||
# working admin key (sk-gw-local-0001) to every install for a while.
|
||||
- src: ./packaging/config.example.yaml
|
||||
dst: /usr/share/llmsproxy/config.example.yaml
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
@ -9,9 +9,19 @@ mkdir -p /etc/llmsproxy/adapters
|
||||
if [ -f /etc/llmsproxy/config.yaml ]; then
|
||||
echo "llmsproxy: keeping existing /etc/llmsproxy/config.yaml"
|
||||
else
|
||||
# seed a starter config from the packaged example
|
||||
cp -f /usr/share/llmsproxy/config.example.yaml /etc/llmsproxy/config.yaml 2>/dev/null || true
|
||||
chmod 0644 /etc/llmsproxy/config.yaml
|
||||
# Do NOT seed a starter config here. Packaging/installing one used to hand
|
||||
# everyone the same file, and while it was copied from the maintainer's
|
||||
# live config it shipped a real, working admin key (sk-gw-local-0001) to
|
||||
# every install.
|
||||
#
|
||||
# Instead, leave the file absent: on first start the binary's EnsureDefault
|
||||
# path creates config.yaml with a FRESH RANDOM admin key and logs it, which
|
||||
# is both unique per install and actually usable. The packaged
|
||||
# config.example.yaml stays in /usr/share as a reference template only.
|
||||
#
|
||||
# (Seeding it with gateway_keys: [] would be worse: the gateway would start
|
||||
# but reject every request, with no way in except editing the file.)
|
||||
echo "llmsproxy: no /etc/llmsproxy/config.yaml — a random admin key will be generated on first start"
|
||||
fi
|
||||
|
||||
# systemd
|
||||
|
||||
Reference in New Issue
Block a user