Files
ModelRouter/internal/gateway/messages_test.go
JianFeeeee 25970a2135 refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据
用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」

## 一、删除 priority(它从未参与跨源调度)

`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。

- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority

## 二、一通道 = 一个适配器 = 一个源(channels 插件)

旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效

新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
  (内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
  跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
  时给当前账号打 termErr,下次选号跳过;成功则清标记。
  ⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发

## 三、discover 支持用已存凭据(真实缺口)

刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。

- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
  做探测,那就成了 SSRF/密钥外带);响应不回显 key

## 四、image_models 显式声明(补元数据缺失)

`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。

不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。

## 五、UI:对话与生图合并为一个导航项

两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。

## 测试

- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
  切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
  一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
  (TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
  (验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"

gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。

BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。
2026-10-19 16:26:59 +08:00

405 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
// 本文件端到端验证 Phase 2 的核心主张:**对外协议由适配器决定,内核只调度**。
//
// 具体做法:起一个真实的 core + gateway,源挂 anthropic 适配器,上游是一个
// 只懂 Anthropic Messages 的假服务器。然后:
//
// 1. Anthropic 客户端形状的请求 → 拿到 Anthropic 形状的响应
// 2. 调度/配额/审计与 /v1/chat/completions 走同一条路径
// 3. 上游若不支持该模型,内核如实回 503,不伪造格式
// anthropicUpstream 是一个只实现 Anthropic Messages 的假上游。
func anthropicUpstream(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/messages") {
// 网关把请求发到了非 Anthropic 路径 —— 显式暴露这个 bug
t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
var probe map[string]interface{}
_ = json.Unmarshal(body, &probe)
if _, ok := probe["messages"]; !ok {
t.Errorf("上游收到没有 messages 的请求体: %s", body)
}
if _, ok := probe["system"]; !ok && probe["system"] == nil {
// system 是可选的,仅在客户端给了的时候才应出现;不强制
_ = ok
}
if _, ok := probe["max_tokens"]; !ok {
t.Errorf("Anthropic 要求 max_tokens,上游请求体却缺这个字段: %s", body)
}
// Streaming requests must get a real Anthropic SSE sequence; a JSON body
// here would leave the adapter with zero frames and the gateway would
// (correctly) reject the stream as empty.
if stream, _ := probe["stream"].(bool); stream {
w.Header().Set("Content-Type", "text/event-stream")
fl, _ := w.(http.Flusher)
frames := []string{
`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_mock","type":"message","role":"assistant","model":"` + probeModel(probe) + `","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`,
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`,
`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
}
for _, f := range frames {
fmt.Fprint(w, f+"\n\n")
if fl != nil {
fl.Flush()
}
}
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"id":"msg_mock","type":"message","role":"assistant","model":"`+probeModel(probe)+`",
"content":[{"type":"thinking","thinking":"let me think"},{"type":"text","text":"pong"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":3,"output_tokens":5}
}`)
}))
}
func probeModel(m map[string]interface{}) string {
if s, ok := m["model"].(string); ok {
return s
}
return ""
}
// messagesGateway 起一个真实网关,源挂 anthropic 适配器。
func messagesGateway(t *testing.T) (*httptest.Server, string) {
t.Helper()
up := anthropicUpstream(t)
t.Cleanup(up.Close)
dir := t.TempDir()
// adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。
// 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载,
// 表现为 "adapter anthropic not loaded"。
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "claude-test",
GatewayKeys: []string{"sk-e2e-0001"},
Sources: []config.Source{{
Name: "anth", BaseURL: up.URL, Adapter: "anthropic",
APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "claude-test", Kind: "chat"}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
gw, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
ts := httptest.NewServer(gw.Handler())
t.Cleanup(ts.Close)
return ts, "sk-e2e-0001"
}
func postJSON(t *testing.T, url, key, body string) (int, string) {
t.Helper()
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// TestMessagesEndpointSpeaksAnthropic 是本文件的主断言:Anthropic 客户端
// 打 /v1/messages,拿到 Anthropic 形状。
func TestMessagesEndpointSpeaksAnthropic(t *testing.T) {
ts, key := messagesGateway(t)
code, body := postJSON(t, ts.URL+"/v1/messages", key,
`{"model":"claude-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("status = %d: %s", code, body)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("响应不是 JSON: %v (%s)", err, body)
}
if m["type"] != "message" {
t.Errorf("type = %v, want message(内核不该退回 OpenAI 的 chat.completion)", m["type"])
}
if m["stop_reason"] != "end_turn" {
t.Errorf("stop_reason = %v, want end_turn", m["stop_reason"])
}
blocks, _ := m["content"].([]interface{})
if len(blocks) != 2 {
t.Fatalf("content blocks = %v, want [thinking, text]", m["content"])
}
if b0 := blocks[0].(map[string]interface{}); b0["type"] != "thinking" || b0["thinking"] != "let me think" {
t.Errorf("首个块应为 thinking: %v", b0)
}
if b1 := blocks[1].(map[string]interface{}); b1["type"] != "text" || b1["text"] != "pong" {
t.Errorf("第二个块应为 text/pong: %v", b1)
}
u := m["usage"].(map[string]interface{})
if u["input_tokens"] != float64(3) || u["output_tokens"] != float64(5) {
t.Errorf("usage 应为 Anthropic 的 input/output 命名: %v", u)
}
// 内核不得混入 OpenAI 形状
for _, forbidden := range []string{`"object"`, `"choices"`, `"chat.completion"`, `"prompt_tokens"`} {
if strings.Contains(body, forbidden) {
t.Errorf("OpenAI 字段 %s 混入 Anthropic 响应: %s", forbidden, body)
}
}
}
// TestMessagesStreamSpeaksAnthropic 验证流式也是 Anthropic 事件序列,
// 而不是 OpenAI SSE。
func TestMessagesStreamSpeaksAnthropic(t *testing.T) {
ts, key := messagesGateway(t)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages",
strings.NewReader(`{"model":"claude-test","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
s := string(body)
if !strings.Contains(s, "message_start") {
t.Errorf("Anthropic 流应以 message_start 开头: %s", s)
}
if !strings.Contains(s, "message_stop") {
t.Errorf("Anthropic 流应以 message_stop 收尾: %s", s)
}
// 内核不得擅自给 Anthropic 流加 OpenAI 的 [DONE]
if strings.Contains(s, "[DONE]") {
t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE] 终止标记: %s", s)
}
if strings.Contains(s, "chat.completion.chunk") {
t.Errorf("流里混入 OpenAI chunk 形状: %s", s)
}
}
// TestMessagesRejectsBadRequest 确认入向校验(Anthropic 要求 max_tokens)
// 与 OpenAI 路径一样在入口拒绝,不占用上游槽位。
func TestMessagesRejectsBadRequest(t *testing.T) {
ts, key := messagesGateway(t)
// Anthropic 允许省略 max_tokens(上游会给默认),所以「有 messages 无
// max_tokens」必须被接受;真正该拒的是没有 messages。
code, body := postJSON(t, ts.URL+"/v1/messages", key,
`{"model":"claude-test","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("有 messages 的请求应被接受(max_tokens 可省),却得 %d: %s", code, body)
}
code, body = postJSON(t, ts.URL+"/v1/messages", key, `{"model":"claude-test"}`)
if code != 400 {
t.Errorf("无 messages 的请求 status = %d, want 400: %s", code, body)
}
// 坏 JSON
code, _ = postJSON(t, ts.URL+"/v1/messages", key, `{"model":`)
if code != 400 {
t.Errorf("坏 JSON status = %d, want 400", code)
}
}
// TestMessagesUnconfiguredFallsBackToOpenai 验证 R5 的兜底在新增端点上同样
// 生效:源没有声明序列化钩子时(此例用 openai 适配器),回 OpenAI 形状。
func TestMessagesUnconfiguredFallsBackToOpenai(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
}))
defer up.Close()
dir := t.TempDir()
// adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。
// 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载,
// 表现为 "adapter anthropic not loaded"。
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "gpt-test",
GatewayKeys: []string{"sk-e2e-0001"},
Sources: []config.Source{{
Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "gpt-test", Kind: "chat"}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
gw, err := New(c)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(gw.Handler())
defer ts.Close()
code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001",
`{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("status = %d: %s", code, body)
}
if !strings.Contains(body, "chat.completion") {
t.Errorf("无序列化钩子的源应回落 OpenAI 兜底形状: %s", body)
}
}
// TestMessagesRequiresAuth 确认新端点走同一套鉴权(不因为是新协议而放行)。
func TestMessagesRequiresAuth(t *testing.T) {
ts, _ := messagesGateway(t)
code, _ := postJSON(t, ts.URL+"/v1/messages", "wrong-key",
`{"model":"claude-test","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`)
if code != 401 {
t.Errorf("错误 key status = %d, want 401", code)
}
// 无 auth
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("无 key status = %d, want 401", resp.StatusCode)
}
_ = context.Background()
}
// TestMessagesIngressYieldsToBundledAnthropic 是 ingress 让出的主断言(用户要求:
// /v1/messages 是内核兜底端点,适配器可声明它、内核主动让出)。
//
// 事实前提:内置 anthropic.lua 自己声明了 adapter.ingress = "/v1/messages",而它
// 默认随包播种。所以真实部署里内核**总是**让出给 anthropic 适配器,
// anthropicRequest.toUnified 退化为纯兜底(仅在适配器被移除/禁用时运行)。
//
// 断言方式:给源挂一个「上游即 Anthropic」的假服务器,客户端送一个 Anthropic
// 形状请求。若内核让出,请求由适配器 deserialize_request 解析 → 路由/调度正常。
// 同时用一个**只有系统消息**的请求验证解析确实走了适配器语义(Go 侧 system
// 处理与 Lua 侧一致)。
func TestMessagesIngressYieldsToBundledAnthropic(t *testing.T) {
ts, key := messagesGateway(t)
if code, body := postJSON(t, ts.URL+"/v1/messages", key,
`{"model":"claude-test","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}`); code != 200 {
t.Fatalf("让出后 /v1/messages 应 200, 实得 %d: %s", code, body)
}
}
// TestAdapterForIngressClaim 直接验证内核的让出判定:声明了 ingress 的适配器
// 会被 AdapterForIngress 找到;未声明的路径返回空(→ 内核用自己的兜底解析)。
func TestAdapterForIngressClaim(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "m",
GatewayKeys: []string{"sk-e2e-0001"},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
vm := c.VM()
// 内置 anthropic 声明了该端点
if got := vm.AdapterForIngress("/v1/messages"); got != "anthropic" {
t.Errorf("内置 anthropic 应认领 /v1/messages, 实得 %q", got)
}
// 未声明的路径 → 空 → 内核走自己的兜底解析
if got := vm.AdapterForIngress("/v1/nothing"); got != "" {
t.Errorf("未声明路径应为空, 实得 %q", got)
}
// 内置 anthropic 确实有 deserialize_request 钩子(否则内核会让出给一个空解析器)
if !vm.HasFn("anthropic", "deserialize_request") {
t.Error("anthropic 应实现 deserialize_request")
}
}
// TestMessagesKernelFallbackWhenNoAdapterClaims 证明兜底仍在:显式删掉所有
// 声明 ingress 的适配器后,内核自己的 anthropicRequest.toUnified 接管,
// /v1/messages 仍能服务(R5 兜底语义)。
func TestMessagesKernelFallbackWhenNoAdapterClaims(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
}))
defer up.Close()
dir := t.TempDir()
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "gpt-test",
GatewayKeys: []string{"sk-e2e-0001"},
Sources: []config.Source{{
Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "gpt-test", Kind: "chat"}},
}},
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
// 摘掉声明 ingress 的适配器(内置 anthropic),模拟"没有适配器认领该端点"。
c.VM().RemoveAdapter("anthropic")
if got := c.VM().AdapterForIngress("/v1/messages"); got != "" {
t.Fatalf("移除后应无适配器认领, 实得 %q", got)
}
gw, err := New(c)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(gw.Handler())
defer ts.Close()
// 内核兜底解析 Anthropic 形状 → 200(走 OpenAI 兜底序列化)
code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001",
`{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 {
t.Fatalf("无适配器认领时内核兜底应仍 200, 实得 %d: %s", code, body)
}
}