mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户判据:「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 表达。
405 lines
15 KiB
Go
405 lines
15 KiB
Go
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)
|
||
}
|
||
}
|