mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容, 已部署的机器不会因为顺序问题立刻失效。 - go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。 - `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、 `/usr/local/bin` 目标全部改为 modelrouter。 - 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与 `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。 - 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN / BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` → `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。 - 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退, 避免升级到一半的 profile 被判成"core binary missing"。 - 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、 旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。 - `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**, 否则会部署出一个"能启动但没有 key、没有源"的空实例。 - 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。 兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留, 它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。 本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、 `adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
255 lines
8.6 KiB
Go
255 lines
8.6 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"modelrouter/internal/config"
|
|
)
|
|
|
|
// These cover the two write-path defects found while adding a model to a live
|
|
// source by hand:
|
|
//
|
|
// 1. PUT /api/sources/{name} was documented in the API index but not
|
|
// implemented, so it answered 405 while POST (which does the same upsert)
|
|
// worked. A caller following the documented contract could not edit a
|
|
// source at all.
|
|
// 2. POST is an upsert that replaces the whole source, so a partial edit that
|
|
// did not carry api_key persisted an empty/placeholder credential. The
|
|
// source kept its name and models and answered 401 on the next call, well
|
|
// after the writing script had exited 0.
|
|
//
|
|
// The second one is the dangerous class: a write that reports success and
|
|
// breaks the source later.
|
|
|
|
// sourceAPIKey reads the stored key back the way an operator would, i.e. what
|
|
// the routes actually use, not what the file happens to contain.
|
|
func sourceAPIKey(t *testing.T, g *Gateway, name string) string {
|
|
t.Helper()
|
|
for _, s := range g.core.Sources() {
|
|
if s.Name == name {
|
|
return s.APIKey
|
|
}
|
|
}
|
|
t.Fatalf("source %q not found", name)
|
|
return ""
|
|
}
|
|
|
|
func seededGateway(t *testing.T) (*Gateway, string) {
|
|
t.Helper()
|
|
up := mockUpstream()
|
|
t.Cleanup(up.Close)
|
|
g := newTestGateway(t, config.Source{
|
|
Name: "seeded",
|
|
BaseURL: up.URL,
|
|
Adapter: "openai",
|
|
APIKey: "sk-real-credential",
|
|
Models: []config.Model{{ID: "m-one", Kind: "chat"}},
|
|
})
|
|
return g, up.URL
|
|
}
|
|
|
|
func TestSourcePutUpdatesSource(t *testing.T) {
|
|
g, up := seededGateway(t)
|
|
|
|
// A single-field edit: change one model's priority, name the source only in
|
|
// the path. This is the shape the API index documents for PUT.
|
|
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
|
|
`{"base_url":%q,"adapter":"openai","api_key":"sk-real-credential","models":[{"id":"m-one","priority":42,"kind":"chat"}]}`, up))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var found bool
|
|
for _, s := range g.core.Sources() {
|
|
if s.Name != "seeded" {
|
|
continue
|
|
}
|
|
found = true
|
|
}
|
|
if !found {
|
|
t.Fatal("source vanished after PUT")
|
|
}
|
|
// And it must be routable, not merely stored.
|
|
if rr := doReq(t, g, "GET", "/v1/models", ""); rr.Code != http.StatusOK ||
|
|
!strings.Contains(rr.Body.String(), "m-one") {
|
|
t.Fatalf("model not routable after PUT: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSourcePutRejectsNameMismatch(t *testing.T) {
|
|
g, _ := seededGateway(t)
|
|
// Body names a different source than the path. Silently preferring either
|
|
// one would let a client edit a source it did not name.
|
|
rr := doReq(t, g, "PUT", "/api/sources/seeded", `{"name":"other","base_url":"http://x","models":[{"id":"m"}]}`)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("name mismatch accepted: status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if sourceAPIKey(t, g, "seeded") != "sk-real-credential" {
|
|
t.Fatal("rejected PUT still mutated the source")
|
|
}
|
|
}
|
|
|
|
func TestSourcePutRequiresNameInPath(t *testing.T) {
|
|
g, _ := seededGateway(t)
|
|
rr := doReq(t, g, "PUT", "/api/sources", `{"base_url":"http://x","models":[{"id":"m"}]}`)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("PUT without a name in the path: status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSourceUpsertKeepsAPIKey(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
sentKey string
|
|
}{
|
|
{"explicit mask", keepExistingAPIKey},
|
|
{"omitted key", ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
g, up := seededGateway(t)
|
|
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
|
|
`{"base_url":%q,"adapter":"openai","api_key":%q,"models":[{"id":"m-one"},{"id":"m-two"}]}`,
|
|
up, tc.sentKey))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if got := sourceAPIKey(t, g, "seeded"); got != "sk-real-credential" {
|
|
t.Fatalf("api_key was clobbered: got %q, want the pre-existing credential", got)
|
|
}
|
|
// The failure this guards is a source that looks configured but
|
|
// cannot authenticate, so assert the route still works.
|
|
if rr := doReq(t, g, "GET", "/v1/models", ""); rr.Code != http.StatusOK {
|
|
t.Fatalf("source unusable after edit: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourceUpsertStillWritesNewKey(t *testing.T) {
|
|
// The keep-existing path must not swallow a deliberate rotation.
|
|
g, up := seededGateway(t)
|
|
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
|
|
`{"base_url":%q,"adapter":"openai","api_key":"sk-rotated","models":[{"id":"m-one"}]}`, up))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if got := sourceAPIKey(t, g, "seeded"); got != "sk-rotated" {
|
|
t.Fatalf("rotation ignored: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSourceUpsertNewSourceMayHaveNoKey(t *testing.T) {
|
|
// Empty is a real value for a credential-less source (local upstreams), so
|
|
// inherit-on-empty must not invent one for a source that does not exist yet.
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t)
|
|
rr := doReq(t, g, "POST", "/api/sources", fmt.Sprintf(
|
|
`{"name":"noauth","base_url":%q,"adapter":"openai","models":[{"id":"local-m"}]}`, up.URL))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if got := sourceAPIKey(t, g, "noauth"); got != "" {
|
|
t.Fatalf("unexpected key on a new credential-less source: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSourceModelIDsIsAdditive(t *testing.T) {
|
|
// "Add one model" is the edit that motivated the defect: it needs no read
|
|
// of the existing list, so it must not require one.
|
|
g, up := seededGateway(t)
|
|
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
|
|
`{"base_url":%q,"adapter":"openai","api_key":%q,"model_ids":["m-two","m-two"," m-three "]}`, up, keepExistingAPIKey))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var ids []string
|
|
for _, s := range g.core.Sources() {
|
|
if s.Name == "seeded" {
|
|
for _, m := range s.Models {
|
|
ids = append(ids, m.ID)
|
|
}
|
|
}
|
|
}
|
|
// m-one kept, m-two not duplicated, blank entries dropped, whitespace trimmed.
|
|
want := map[string]int{"m-one": 1, "m-two": 1, "m-three": 1}
|
|
if len(ids) != len(want) {
|
|
t.Fatalf("models=%v, want exactly %v", ids, want)
|
|
}
|
|
seen := map[string]int{}
|
|
for _, id := range ids {
|
|
seen[id]++
|
|
}
|
|
for id, n := range want {
|
|
if seen[id] != n {
|
|
t.Fatalf("models=%v: %q appears %d times, want %d", ids, id, seen[id], n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSourceWriteRejectsNonAdmin(t *testing.T) {
|
|
g, up := seededGateway(t)
|
|
for _, m := range []string{"POST", "PUT", "DELETE"} {
|
|
req, _ := http.NewRequest(m, "/api/sources/seeded", strings.NewReader(fmt.Sprintf(
|
|
`{"name":"seeded","base_url":%q,"models":[{"id":"m"}]}`, up)))
|
|
req.Header.Set("Authorization", "Bearer sk-not-a-real-key")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("%s allowed for an unknown key: %s", m, rr.Body.String())
|
|
}
|
|
}
|
|
if sourceAPIKey(t, g, "seeded") != "sk-real-credential" {
|
|
t.Fatal("unauthenticated write mutated the source")
|
|
}
|
|
}
|
|
|
|
// TestSourceWritePersistsToDisk guards the difference between "the in-memory
|
|
// registry accepted it" and "it will survive a restart". The api_key must be
|
|
// sealed at rest, never the literal placeholder.
|
|
func TestSourceWritePersistsToDisk(t *testing.T) {
|
|
g, up := seededGateway(t)
|
|
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
|
|
`{"base_url":%q,"adapter":"openai","api_key":%q,"models":[{"id":"m-one"}]}`, up, keepExistingAPIKey))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
raw, err := os.ReadFile(g.core.Config().Path)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
text := string(raw)
|
|
if strings.Contains(text, keepExistingAPIKey) {
|
|
t.Fatal("the keep-mask was persisted as a literal api_key")
|
|
}
|
|
if strings.Contains(text, "sk-real-credential") {
|
|
t.Fatal("api_key stored in plaintext; it must be sealed at rest")
|
|
}
|
|
if !strings.Contains(text, "m-one") {
|
|
t.Fatal("source not persisted")
|
|
}
|
|
// The masked GET view must not leak the credential either.
|
|
rr = doReq(t, g, "GET", "/api/v1/sources/seeded", "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("get status=%d", rr.Code)
|
|
}
|
|
var view struct {
|
|
Source struct {
|
|
APIKey string `json:"api_key"`
|
|
} `json:"source"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if view.Source.APIKey == "sk-real-credential" {
|
|
t.Fatal("GET /api/v1/sources leaked the plaintext api_key")
|
|
}
|
|
}
|