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/免费池的既有改动,另批提交)。
215 lines
8.0 KiB
Go
215 lines
8.0 KiB
Go
package gateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"modelrouter/internal/provider"
|
||
)
|
||
|
||
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
|
||
// started this: the chat page listed image models beside chat ones, and picking
|
||
// one posted to /api/chat, which cannot serve it.
|
||
//
|
||
// The contract is asserted on the HTML source rather than by running a browser,
|
||
// because the failure this must catch is a source-level one — an <option>
|
||
// without `disabled`. A DOM assertion in a headless browser would pass right
|
||
// up until someone removed the attribute, and then only if a CI machine had the
|
||
// whole stack up.
|
||
func TestUI_ChatPageGroupsImageModels(t *testing.T) {
|
||
ui := uiSource(t)
|
||
|
||
// The dropdown must read model_kinds, not the flat models list. Reading the
|
||
// flat list is what made chat and image models indistinguishable.
|
||
if !strings.Contains(ui, "model_kinds") {
|
||
t.Error("chat page never reads model_kinds; image models will be " +
|
||
"indistinguishable from chat models again")
|
||
}
|
||
|
||
// Image options must be rendered disabled. A plain String.replace over the
|
||
// joined HTML only disables the FIRST match — that bug shipped once here
|
||
// (one source disabled, five selectable) — so the assertion looks for the
|
||
// per-item map, not for a global replace.
|
||
if strings.Contains(ui, `imgOpts.join("").replace`) {
|
||
t.Error("chat page disables image options with String.replace over the " +
|
||
"joined HTML, which only rewrites the first option")
|
||
}
|
||
if !strings.Contains(ui, "disabled") {
|
||
t.Error("chat page never emits a disabled attribute for image models")
|
||
}
|
||
}
|
||
|
||
// TestUI_ImageModeIsReachableFromChatTab 钉住生图**仍可达**。
|
||
//
|
||
// ★ 断言已随 UI 结构调整:生图不再是独立侧栏项,而是对话页里的一个分段模式。
|
||
// 原先这条测试钉的是 `data-tab="image"` / `id="tab-image"` / `tab === "image"`
|
||
// —— 三者都已随合并移除。若继续钉旧结构,它只会红着挡住重构,而检不出
|
||
// "生图入口彻底消失"这种真回归。所以改为钉**合并后的可达性**:
|
||
// 分段控件在、切换函数在、面板容器在、端点仍在。
|
||
//
|
||
// 为什么"可达性"才是要守的东西:合并的动机是少一个导航项,而不是把生图功能
|
||
// 藏起来。只要用户能从对话页一键切到生图并真的发出请求,这个功能就是完整的。
|
||
func TestUI_ImageModeIsReachableFromChatTab(t *testing.T) {
|
||
ui := uiSource(t)
|
||
|
||
for _, want := range []string{
|
||
`class="chat-mode-bar"`, // 分段栏存在
|
||
`switchChatMode('image')`, // 能切到生图
|
||
`id="ct-image-pane"`, // 生图面板容器
|
||
`function renderImage`, // 渲染器还在
|
||
`/v1/images/generations`, // 真正被调用的端点
|
||
} {
|
||
if !strings.Contains(ui, want) {
|
||
t.Errorf("生图模式不可达,缺少 %q", want)
|
||
}
|
||
}
|
||
|
||
// 合并后**不应**再出现独立 tab 的三件套 —— 它们会让 goTab 去找一个不存在的
|
||
// 面板($("#tab-image") 为 null ⇒ 点侧栏抛错)。
|
||
for _, gone := range []string{
|
||
`data-tab="image"`,
|
||
`id="tab-image"`,
|
||
`tab === "image"`,
|
||
} {
|
||
if strings.Contains(ui, gone) {
|
||
t.Errorf("生图已并入对话页,不应再有独立 tab 结构 %q"+
|
||
"(否则 goTab 会找一个不存在的面板)", gone)
|
||
}
|
||
}
|
||
|
||
// 面板必须在 DOM 里且**不能被初始隐藏到底**:切换函数用 display 控制。
|
||
if !strings.Contains(ui, `ct-chat-pane`) {
|
||
t.Error("缺少对话面板容器 ct-chat-pane(切换需要两个容器同时存在)")
|
||
}
|
||
}
|
||
|
||
// TestUI_ImagePageCallsTheImageEndpoint is the discriminator: a page that
|
||
// renders an image result but posts to the chat endpoint would produce exactly
|
||
// the original failure, just prettier.
|
||
func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) {
|
||
ui := uiSource(t)
|
||
|
||
i := strings.Index(ui, "async function sendImage(")
|
||
if i < 0 {
|
||
t.Fatal("sendImage() not found")
|
||
}
|
||
// Bound the search to the function body rather than the whole file, so a
|
||
// /v1/images/generations mention elsewhere cannot satisfy this.
|
||
body := ui[i:]
|
||
if end := strings.Index(body, "\n function "); end > 0 {
|
||
body = body[:end]
|
||
}
|
||
if end := strings.Index(body, "\n async function "); end > 0 {
|
||
if s := body[:end]; strings.Contains(s, "/v1/images/generations") {
|
||
body = s
|
||
} else {
|
||
body = body[:end]
|
||
}
|
||
}
|
||
if !strings.Contains(body, "/v1/images/generations") {
|
||
t.Error("sendImage() does not post to /v1/images/generations")
|
||
}
|
||
}
|
||
|
||
// TestSourceStatus_ExposesModelKindsToTheUI is the server half of the same
|
||
// contract: the field the UI reads must actually be serialized.
|
||
func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) {
|
||
st := provider.SourceStatus{
|
||
Name: "vela",
|
||
Models: []string{"claude-sonnet-5.5", "gpt-image-2"},
|
||
ModelKinds: []provider.ModelKindInfo{
|
||
{ID: "claude-sonnet-5.5", Kind: "chat"},
|
||
{ID: "gpt-image-2", Kind: "image"},
|
||
},
|
||
}
|
||
b, err := json.Marshal(st)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var got map[string]interface{}
|
||
if err := json.Unmarshal(b, &got); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mk, ok := got["model_kinds"].([]interface{})
|
||
if !ok || len(mk) != 2 {
|
||
t.Fatalf("model_kinds = %v, want 2 entries; the UI cannot split the list without it", got["model_kinds"])
|
||
}
|
||
last := mk[1].(map[string]interface{})
|
||
if last["kind"] != "image" {
|
||
t.Errorf("model_kinds[1].kind = %v, want \"image\"", last["kind"])
|
||
}
|
||
}
|
||
|
||
// TestUI_BaseURLFieldExplainsWhereToCut keeps the second bug fixed: the URL
|
||
// hint has to mention the concrete failure, not just say "enter the URL".
|
||
func TestUI_BaseURLFieldExplainsWhereToCut(t *testing.T) {
|
||
ui := uiSource(t)
|
||
if !strings.Contains(ui, "urlHint") {
|
||
t.Fatal("no urlHint; the base_url field gives no guidance about /v1")
|
||
}
|
||
hint := ui[strings.Index(ui, "urlHint:"):]
|
||
if len(hint) > 700 {
|
||
hint = hint[:700]
|
||
}
|
||
// The trap is specific and reproducible: base_url ending in /v1 makes chat
|
||
// work and image generation 404. Saying only "enter the URL" reproduces the
|
||
// confusion the hint exists to remove.
|
||
for _, want := range []string{"/v1", "404"} {
|
||
if !strings.Contains(hint, want) {
|
||
t.Errorf("urlHint does not mention %q — the hint must name the concrete failure", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestImageEndpointComposesUnderRealConditions proves the hint describes a
|
||
// reachable path rather than a plausible-sounding one.
|
||
//
|
||
// The case that matters: base_url already ends in /v1 (which is what an
|
||
// operator pastes from an SDK example), so the RELATIVE image endpoint composes
|
||
// to …/v1/images/generations and works, while the gateway's default ABSOLUTE
|
||
// path would produce …/v1/v1/… and 404.
|
||
//
|
||
// The doubled form is served as a 404 here so the test would fail if the code
|
||
// ever reverted to relying on the default.
|
||
func TestImageEndpointComposesUnderRealConditions(t *testing.T) {
|
||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/v1/images/generations":
|
||
_, _ = w.Write([]byte(`{"data":[{"url":"http://example.invalid/x.png"}]}`))
|
||
default:
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}
|
||
}))
|
||
defer up.Close()
|
||
|
||
// base_url as an operator types it: host root + /v1.
|
||
base := up.URL + "/v1"
|
||
|
||
// What discovery writes when base carries a version segment.
|
||
relative := base + "/images/generations"
|
||
resp, err := http.Post(relative, "application/json", strings.NewReader(`{"model":"m"}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Errorf("POST %s = %d, want 200 — the form discovery writes must work", relative, resp.StatusCode)
|
||
}
|
||
|
||
// What the gateway's built-in default would produce for the same base_url.
|
||
doubled := base + "/v1/images/generations"
|
||
resp2, err := http.Post(doubled, "application/json", strings.NewReader(`{"model":"m"}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
resp2.Body.Close()
|
||
if resp2.StatusCode != http.StatusNotFound {
|
||
t.Errorf("POST %s = %d, want 404 — this test's premise is that the "+
|
||
"default image path doubles the version segment; if the upstream ever "+
|
||
"accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode)
|
||
}
|
||
}
|