Files
ModelRouter/internal/gateway/sources_api_test.go
JianFeeeee 688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 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/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00

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")
}
}