mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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 ./... 全绿。
154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestComposeImageEndpoint_NoVersionBaseLeavesDefault is the counterpart of the
|
|
// /v1 case: when the base carries no version segment, discovery must emit "" so
|
|
// the gateway's own default "/v1/images/generations" composes.
|
|
//
|
|
// This is the shape every currently-working source uses — siliconflow, the only
|
|
// source in production with kind:image, has base_url
|
|
// "https://api.siliconflow.cn" — so writing a non-empty value here would break
|
|
// exactly the configuration that is known to work today.
|
|
func TestComposeImageEndpoint_NoVersionBaseLeavesDefault(t *testing.T) {
|
|
if got := composeImageEndpoint("https://api.siliconflow.cn"); got != "" {
|
|
t.Errorf("composeImageEndpoint = %q, want \"\" so the built-in default composes", got)
|
|
}
|
|
// And the composition it implies must be the vendor's real path.
|
|
base := "https://api.siliconflow.cn"
|
|
composed := base + "/v1/images/generations"
|
|
if !strings.HasSuffix(composed, "/v1/images/generations") {
|
|
t.Errorf("composed URL %q lost the image path", composed)
|
|
}
|
|
}
|
|
|
|
// TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned
|
|
// distinguishes two cases that look identical from the outside.
|
|
//
|
|
// bare host + catalog at "/models" → chat is "/chat/completions" (the
|
|
// adapter default, no endpoint written)
|
|
// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions",
|
|
// so an endpoint MUST be written
|
|
//
|
|
// The catalog path is the only evidence of whether the upstream wants the
|
|
// version segment, and getting this backwards sends every chat request to a
|
|
// path the vendor does not serve.
|
|
func TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
catalogPath string
|
|
wantEndpoint string
|
|
wantImage string
|
|
}{
|
|
{
|
|
name: "catalog at the bare /models needs no endpoint",
|
|
catalogPath: "/models",
|
|
wantEndpoint: "",
|
|
wantImage: "",
|
|
},
|
|
{
|
|
name: "catalog at /v1/models requires /v1 on the chat endpoint",
|
|
catalogPath: "/v1/models",
|
|
wantEndpoint: "/v1/chat/completions",
|
|
wantImage: "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != tc.catalogPath {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res, err := DiscoverModels(context.Background(), srv.URL, "k")
|
|
if err != nil {
|
|
t.Fatalf("discovery failed: %v", err)
|
|
}
|
|
if res.BaseURL != srv.URL {
|
|
t.Errorf("base = %q, want %q", res.BaseURL, srv.URL)
|
|
}
|
|
if res.Endpoint != tc.wantEndpoint {
|
|
t.Errorf("endpoint = %q, want %q", res.Endpoint, tc.wantEndpoint)
|
|
}
|
|
if res.ImageEndpoint != tc.wantImage {
|
|
t.Errorf("image_endpoint = %q, want %q", res.ImageEndpoint, tc.wantImage)
|
|
}
|
|
// Whatever it wrote, the chat URL must reach the same version
|
|
// prefix the catalog was served under.
|
|
ep := res.Endpoint
|
|
if ep == "" {
|
|
ep = "/chat/completions"
|
|
}
|
|
if got := res.BaseURL + ep; strings.Contains(got, "/v1/v1") {
|
|
t.Errorf("chat URL %q has a doubled version", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComposeEndpoints_BothFormsProduceWorkingURLs is the end-to-end
|
|
// invariant across the two shapes, stated as the URLs the gateway will build.
|
|
//
|
|
// A base with a version and no image_endpoint produces "/v1/v1/…" — the exact
|
|
// 404 observed before composeImageEndpoint existed. A base without one and a
|
|
// non-empty image_endpoint produces a path missing the version. Both are
|
|
// silent, expensive mistakes, so they are asserted literally.
|
|
func TestComposeEndpoints_BothFormsProduceWorkingURLs(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
base string
|
|
wantChat string
|
|
wantImage string
|
|
}{
|
|
{
|
|
name: "bare host: adapter and image defaults compose",
|
|
base: "https://h",
|
|
wantChat: "https://h/chat/completions",
|
|
// openai's adapter endpoint is /chat/completions (verified in
|
|
// internal/lua/adapters/openai.lua: adapter.endpoint).
|
|
wantImage: "https://h/v1/images/generations",
|
|
},
|
|
{
|
|
name: "versioned base: the version moves onto both endpoints",
|
|
base: "https://h/v1",
|
|
wantChat: "https://h/v1/chat/completions",
|
|
wantImage: "https://h/v1/images/generations",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ep := composeEndpoint("/models", tc.base, tc.base)
|
|
if ep == "" {
|
|
ep = "/chat/completions" // the adapter default
|
|
}
|
|
iep := composeImageEndpoint(tc.base)
|
|
if iep == "" {
|
|
iep = "/v1/images/generations" // the gateway default
|
|
}
|
|
gotChat := tc.base + ep
|
|
gotImage := tc.base + iep
|
|
if gotChat != tc.wantChat {
|
|
t.Errorf("chat URL = %q, want %q", gotChat, tc.wantChat)
|
|
}
|
|
if gotImage != tc.wantImage {
|
|
t.Errorf("image URL = %q, want %q", gotImage, tc.wantImage)
|
|
}
|
|
for _, u := range []string{gotChat, gotImage} {
|
|
if strings.Contains(u, "/v1/v1") {
|
|
t.Errorf("%q has a doubled version segment", u)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|