Files
ModelRouter/internal/gateway/ui_image_page_test.go
JianFeeeee e4111337cd feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。

- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
  (content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
  system 支持字符串与 [{type:text,text}] 两种写法)。
  调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
  的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close,
  把统一格式译回 Anthropic Messages 形状(含 content 分块、
  stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
  message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。

内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。

测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
  假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
  鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
  这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。

golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00

196 lines
7.0 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"llmsproxy/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_HasDedicatedImagePage pins the second half of the fix: image models
// need somewhere they CAN be tested.
func TestUI_HasDedicatedImagePage(t *testing.T) {
ui := uiSource(t)
for _, want := range []string{
`data-tab="image"`, // sidebar entry
`id="tab-image"`, // pane
`function renderImage`, // renderer registered with refresh()
`tab === "image"`, // dispatched
`/v1/images/generations`, // the endpoint the page actually calls
} {
if !strings.Contains(ui, want) {
t.Errorf("image page is missing %q", want)
}
}
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
// visibility, so a pane outside that list is present but permanently hidden —
// which reads as "the page is blank" because the content is in the DOM.
if !strings.Contains(ui, `"image"`) {
t.Error(`"image" does not appear in the TABS list`)
}
}
// 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)
}
}