refactor: 优化 URL 路径拼接,修复 /v1 重复问题
## 主要变更 **核心修改**: - 路由定义:/:protocol/v1/*path → /:protocol/*path - proxy_handler:nativePath 直接使用 path 参数,不添加 /v1 前缀 - OpenAI 适配器:DetectInterfaceType 和 BuildUrl 去掉 /v1 前缀 - Anthropic 适配器:保持 /v1 前缀(Claude Code 兼容) **URL 格式变化**: - OpenAI: /openai/v1/chat/completions → /openai/chat/completions - Anthropic: /anthropic/v1/messages (保持不变) **base_url 配置**: - OpenAI: 配置到版本路径,如 https://api.openai.com/v1 - Anthropic: 不配置版本路径,如 https://api.anthropic.com ## 测试验证 - 所有单元测试通过 - 所有集成测试通过 - 真实 API 测试验证成功 - 跨协议转换正常工作 ## 文档更新 - 更新 backend/README.md URL 格式说明 - 同步 OpenSpec 规范文件
This commit is contained in:
@@ -317,7 +317,13 @@ GET /anthropic/v1/models
|
||||
|
||||
**Protocol 字段**:标识上游供应商使用的协议类型,可选值 `"openai"`(默认)、`"anthropic"`。
|
||||
|
||||
**base_url 说明**:应配置到 API 版本路径,不包含具体端点(如 OpenAI: `https://api.openai.com/v1`,GLM: `https://open.bigmodel.cn/api/paas/v4`)。
|
||||
**base_url 说明**:
|
||||
- OpenAI 协议:配置到 API 版本路径,如 `https://api.openai.com/v1`、`https://open.bigmodel.cn/api/paas/v4`
|
||||
- Anthropic 协议:配置到域名,不包含版本路径,如 `https://api.anthropic.com`
|
||||
|
||||
**对外 URL 格式**:
|
||||
- OpenAI 协议:`/{protocol}/{endpoint}`,如 `/openai/chat/completions`、`/openai/models`
|
||||
- Anthropic 协议:`/{protocol}/v1/{endpoint}`,如 `/anthropic/v1/messages`、`/anthropic/v1/models`
|
||||
|
||||
#### 模型管理
|
||||
|
||||
|
||||
@@ -207,8 +207,8 @@ func formatAddr(port int) string {
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
|
||||
// 统一代理入口: /{protocol}/v1/{path}
|
||||
r.Any("/:protocol/v1/*path", proxyHandler.HandleProxy)
|
||||
// 统一代理入口: /{protocol}/{path}
|
||||
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||
|
||||
// 供应商管理 API
|
||||
providers := r.Group("/api/providers")
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestEngine_Use(t *testing.T) {
|
||||
_ = engine.RegisterAdapter(providerAdapter)
|
||||
|
||||
_, err := engine.ConvertHttpRequest(HTTPRequestSpec{
|
||||
URL: "/v1/chat/completions", Method: "POST", Body: []byte(`{}`),
|
||||
URL: "/chat/completions", Method: "POST", Body: []byte(`{}`),
|
||||
}, "client", "provider", NewTargetProvider("https://example.com", "key", "model"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
@@ -75,7 +75,7 @@ func TestConvertHttpRequest_DecodeError(t *testing.T) {
|
||||
_ = engine.RegisterAdapter(newMockAdapter("provider", false))
|
||||
|
||||
_, err := engine.ConvertHttpRequest(HTTPRequestSpec{
|
||||
URL: "/v1/chat/completions", Method: "POST", Body: []byte(`{}`),
|
||||
URL: "/chat/completions", Method: "POST", Body: []byte(`{}`),
|
||||
}, "client", "provider", NewTargetProvider("", "", ""))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestConvertHttpRequest_EncodeError(t *testing.T) {
|
||||
_ = engine.RegisterAdapter(providerAdapter)
|
||||
|
||||
_, err := engine.ConvertHttpRequest(HTTPRequestSpec{
|
||||
URL: "/v1/chat/completions", Method: "POST", Body: []byte(`{}`),
|
||||
URL: "/chat/completions", Method: "POST", Body: []byte(`{}`),
|
||||
}, "client", "provider", NewTargetProvider("", "", ""))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func TestConvertHttpRequest_ModelsInterface_Passthrough(t *testing.T) {
|
||||
|
||||
body := []byte(`{"object":"list","data":[]}`)
|
||||
result, err := engine.ConvertHttpRequest(HTTPRequestSpec{
|
||||
URL: "/v1/models", Method: "GET", Body: body,
|
||||
URL: "/models", Method: "GET", Body: body,
|
||||
}, "client", "provider", NewTargetProvider("https://example.com", "key", ""))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, result.Body)
|
||||
|
||||
@@ -260,7 +260,7 @@ func TestDetectInterfaceType(t *testing.T) {
|
||||
adapter.ifaceType = InterfaceTypeChat
|
||||
_ = engine.RegisterAdapter(adapter)
|
||||
|
||||
ifaceType, err := engine.DetectInterfaceType("/v1/chat/completions", "test")
|
||||
ifaceType, err := engine.DetectInterfaceType("/chat/completions", "test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, InterfaceTypeChat, ifaceType)
|
||||
}
|
||||
@@ -278,9 +278,9 @@ func TestConvertHttpRequest_Passthrough(t *testing.T) {
|
||||
engine := NewConversionEngine(registry, nil)
|
||||
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
|
||||
|
||||
provider := NewTargetProvider("https://api.openai.com", "sk-test", "gpt-4")
|
||||
provider := NewTargetProvider("https://api.openai.com/v1", "sk-test", "gpt-4")
|
||||
spec := HTTPRequestSpec{
|
||||
URL: "/v1/chat/completions",
|
||||
URL: "/chat/completions",
|
||||
Method: "POST",
|
||||
Body: []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`),
|
||||
}
|
||||
|
||||
@@ -29,27 +29,27 @@ func (a *Adapter) SupportsPassthrough() bool { return true }
|
||||
// DetectInterfaceType 根据路径检测接口类型
|
||||
func (a *Adapter) DetectInterfaceType(nativePath string) conversion.InterfaceType {
|
||||
switch {
|
||||
case nativePath == "/v1/chat/completions":
|
||||
case nativePath == "/chat/completions":
|
||||
return conversion.InterfaceTypeChat
|
||||
case nativePath == "/v1/models":
|
||||
case nativePath == "/models":
|
||||
return conversion.InterfaceTypeModels
|
||||
case isModelInfoPath(nativePath):
|
||||
return conversion.InterfaceTypeModelInfo
|
||||
case nativePath == "/v1/embeddings":
|
||||
case nativePath == "/embeddings":
|
||||
return conversion.InterfaceTypeEmbeddings
|
||||
case nativePath == "/v1/rerank":
|
||||
case nativePath == "/rerank":
|
||||
return conversion.InterfaceTypeRerank
|
||||
default:
|
||||
return conversion.InterfaceTypePassthrough
|
||||
}
|
||||
}
|
||||
|
||||
// isModelInfoPath 判断是否为模型详情路径(/v1/models/{id},允许 id 含 /)
|
||||
// isModelInfoPath 判断是否为模型详情路径(/models/{id},允许 id 含 /)
|
||||
func isModelInfoPath(path string) bool {
|
||||
if !strings.HasPrefix(path, "/v1/models/") {
|
||||
if !strings.HasPrefix(path, "/models/") {
|
||||
return false
|
||||
}
|
||||
suffix := path[len("/v1/models/"):]
|
||||
suffix := path[len("/models/"):]
|
||||
return suffix != ""
|
||||
}
|
||||
|
||||
@@ -57,13 +57,13 @@ func isModelInfoPath(path string) bool {
|
||||
func (a *Adapter) BuildUrl(nativePath string, interfaceType conversion.InterfaceType) string {
|
||||
switch interfaceType {
|
||||
case conversion.InterfaceTypeChat:
|
||||
return "/v1/chat/completions"
|
||||
return "/chat/completions"
|
||||
case conversion.InterfaceTypeModels:
|
||||
return "/v1/models"
|
||||
return "/models"
|
||||
case conversion.InterfaceTypeEmbeddings:
|
||||
return "/v1/embeddings"
|
||||
return "/embeddings"
|
||||
case conversion.InterfaceTypeRerank:
|
||||
return "/v1/rerank"
|
||||
return "/rerank"
|
||||
default:
|
||||
return nativePath
|
||||
}
|
||||
@@ -218,12 +218,12 @@ func (a *Adapter) EncodeRerankResponse(resp *canonical.CanonicalRerankResponse)
|
||||
return encodeRerankResponse(resp)
|
||||
}
|
||||
|
||||
// ExtractUnifiedModelID 从路径中提取统一模型 ID(/v1/models/{provider_id}/{model_name})
|
||||
// ExtractUnifiedModelID 从路径中提取统一模型 ID(/models/{provider_id}/{model_name})
|
||||
func (a *Adapter) ExtractUnifiedModelID(nativePath string) (string, error) {
|
||||
if !strings.HasPrefix(nativePath, "/v1/models/") {
|
||||
if !strings.HasPrefix(nativePath, "/models/") {
|
||||
return "", fmt.Errorf("不是模型详情路径: %s", nativePath)
|
||||
}
|
||||
suffix := nativePath[len("/v1/models/"):]
|
||||
suffix := nativePath[len("/models/"):]
|
||||
if suffix == "" {
|
||||
return "", fmt.Errorf("路径缺少模型 ID")
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ func TestAdapter_DetectInterfaceType(t *testing.T) {
|
||||
path string
|
||||
expected conversion.InterfaceType
|
||||
}{
|
||||
{"聊天补全", "/v1/chat/completions", conversion.InterfaceTypeChat},
|
||||
{"模型列表", "/v1/models", conversion.InterfaceTypeModels},
|
||||
{"模型详情", "/v1/models/gpt-4", conversion.InterfaceTypeModelInfo},
|
||||
{"嵌入接口", "/v1/embeddings", conversion.InterfaceTypeEmbeddings},
|
||||
{"重排序接口", "/v1/rerank", conversion.InterfaceTypeRerank},
|
||||
{"未知路径", "/v1/unknown", conversion.InterfaceTypePassthrough},
|
||||
{"聊天补全", "/chat/completions", conversion.InterfaceTypeChat},
|
||||
{"模型列表", "/models", conversion.InterfaceTypeModels},
|
||||
{"模型详情", "/models/gpt-4", conversion.InterfaceTypeModelInfo},
|
||||
{"嵌入接口", "/embeddings", conversion.InterfaceTypeEmbeddings},
|
||||
{"重排序接口", "/rerank", conversion.InterfaceTypeRerank},
|
||||
{"未知路径", "/unknown", conversion.InterfaceTypePassthrough},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -53,11 +53,11 @@ func TestAdapter_BuildUrl(t *testing.T) {
|
||||
interfaceType conversion.InterfaceType
|
||||
expected string
|
||||
}{
|
||||
{"聊天", "/v1/chat/completions", conversion.InterfaceTypeChat, "/v1/chat/completions"},
|
||||
{"模型", "/v1/models", conversion.InterfaceTypeModels, "/v1/models"},
|
||||
{"嵌入", "/v1/embeddings", conversion.InterfaceTypeEmbeddings, "/v1/embeddings"},
|
||||
{"重排序", "/v1/rerank", conversion.InterfaceTypeRerank, "/v1/rerank"},
|
||||
{"默认透传", "/v1/other", conversion.InterfaceTypePassthrough, "/v1/other"},
|
||||
{"聊天", "/chat/completions", conversion.InterfaceTypeChat, "/chat/completions"},
|
||||
{"模型", "/models", conversion.InterfaceTypeModels, "/models"},
|
||||
{"嵌入", "/embeddings", conversion.InterfaceTypeEmbeddings, "/embeddings"},
|
||||
{"重排序", "/rerank", conversion.InterfaceTypeRerank, "/rerank"},
|
||||
{"默认透传", "/other", conversion.InterfaceTypePassthrough, "/other"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -118,13 +118,13 @@ func TestIsModelInfoPath(t *testing.T) {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"model_info", "/v1/models/gpt-4", true},
|
||||
{"model_info_with_dots", "/v1/models/gpt-4.1-preview", true},
|
||||
{"models_list", "/v1/models", false},
|
||||
{"nested_path", "/v1/models/gpt-4/versions", true},
|
||||
{"empty_suffix", "/v1/models/", false},
|
||||
{"unrelated", "/v1/chat/completions", false},
|
||||
{"partial_prefix", "/v1/model", false},
|
||||
{"model_info", "/models/gpt-4", true},
|
||||
{"model_info_with_dots", "/models/gpt-4.1-preview", true},
|
||||
{"models_list", "/models", false},
|
||||
{"nested_path", "/models/gpt-4/versions", true},
|
||||
{"empty_suffix", "/models/", false},
|
||||
{"unrelated", "/chat/completions", false},
|
||||
{"partial_prefix", "/model", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -18,40 +18,40 @@ func TestExtractUnifiedModelID(t *testing.T) {
|
||||
a := NewAdapter()
|
||||
|
||||
t.Run("standard_path", func(t *testing.T) {
|
||||
id, err := a.ExtractUnifiedModelID("/v1/models/openai/gpt-4")
|
||||
id, err := a.ExtractUnifiedModelID("/models/openai/gpt-4")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "openai/gpt-4", id)
|
||||
})
|
||||
|
||||
t.Run("multi_segment_path", func(t *testing.T) {
|
||||
id, err := a.ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")
|
||||
id, err := a.ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "azure/accounts/org/models/gpt-4", id)
|
||||
})
|
||||
|
||||
t.Run("single_segment", func(t *testing.T) {
|
||||
id, err := a.ExtractUnifiedModelID("/v1/models/gpt-4")
|
||||
id, err := a.ExtractUnifiedModelID("/models/gpt-4")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "gpt-4", id)
|
||||
})
|
||||
|
||||
t.Run("non_model_path", func(t *testing.T) {
|
||||
_, err := a.ExtractUnifiedModelID("/v1/chat/completions")
|
||||
_, err := a.ExtractUnifiedModelID("/chat/completions")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty_suffix", func(t *testing.T) {
|
||||
_, err := a.ExtractUnifiedModelID("/v1/models/")
|
||||
_, err := a.ExtractUnifiedModelID("/models/")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("models_list_no_slash", func(t *testing.T) {
|
||||
_, err := a.ExtractUnifiedModelID("/v1/models")
|
||||
_, err := a.ExtractUnifiedModelID("/models")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("unrelated_path", func(t *testing.T) {
|
||||
_, err := a.ExtractUnifiedModelID("/v1/other")
|
||||
_, err := a.ExtractUnifiedModelID("/other")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -344,12 +344,12 @@ func TestIsModelInfoPath_UnifiedModelID(t *testing.T) {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"simple_model_id", "/v1/models/gpt-4", true},
|
||||
{"unified_model_id_with_slash", "/v1/models/openai/gpt-4", true},
|
||||
{"models_list", "/v1/models", false},
|
||||
{"models_list_trailing_slash", "/v1/models/", false},
|
||||
{"chat_completions", "/v1/chat/completions", false},
|
||||
{"deeply_nested", "/v1/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
|
||||
{"simple_model_id", "/models/gpt-4", true},
|
||||
{"unified_model_id_with_slash", "/models/openai/gpt-4", true},
|
||||
{"models_list", "/models", false},
|
||||
{"models_list_trailing_slash", "/models/", false},
|
||||
{"chat_completions", "/chat/completions", false},
|
||||
{"deeply_nested", "/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -49,12 +49,12 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 原始路径: /v1/{path}
|
||||
// 原始路径: /{path}
|
||||
path := c.Param("path")
|
||||
if strings.HasPrefix(path, "/") {
|
||||
path = path[1:]
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
nativePath := "/v1/" + path
|
||||
nativePath := path
|
||||
|
||||
// 获取 client adapter
|
||||
registry := h.engine.GetRegistry()
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestProxyHandler_HandleProxy_NonStreamSuccess(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -162,7 +162,7 @@ func TestProxyHandler_HandleProxy_RoutingError_WithBody(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"unknown","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 404, w.Code)
|
||||
@@ -186,7 +186,7 @@ func TestProxyHandler_HandleProxy_ConversionError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 500, w.Code)
|
||||
@@ -210,7 +210,7 @@ func TestProxyHandler_HandleProxy_ClientSendError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 500, w.Code)
|
||||
@@ -242,7 +242,7 @@ func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -268,7 +268,7 @@ func TestProxyHandler_HandleProxy_StreamError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 500, w.Code)
|
||||
@@ -296,7 +296,7 @@ func TestProxyHandler_ForwardPassthrough_GET(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -311,7 +311,7 @@ func TestProxyHandler_ForwardPassthrough_UnsupportedProtocol(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "unknown"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/unknown/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/unknown/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 400, w.Code)
|
||||
@@ -326,7 +326,7 @@ func TestProxyHandler_ForwardPassthrough_NoProviders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
// Models 接口现在本地聚合,返回空列表 200
|
||||
@@ -366,7 +366,7 @@ func TestProxyHandler_HandleProxy_ProviderProtocolDefault(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -418,7 +418,7 @@ func TestProxyHandler_HandleProxy_EmptyBody(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -448,7 +448,7 @@ func TestProxyHandler_HandleStream_MidStreamError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -482,7 +482,7 @@ func TestProxyHandler_HandleStream_FlushOutput(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -511,7 +511,7 @@ func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 500, w.Code)
|
||||
@@ -533,7 +533,7 @@ func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 500, w.Code)
|
||||
@@ -565,7 +565,7 @@ func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 500, w.Code)
|
||||
@@ -593,7 +593,7 @@ func TestProxyHandler_HandleNonStream_ResponseHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -629,7 +629,7 @@ func TestProxyHandler_ForwardPassthrough_CrossProtocol(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -657,7 +657,7 @@ func TestProxyHandler_ForwardPassthrough_NoBody_NoModel(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -673,10 +673,10 @@ func TestIsStreamRequest_EdgeCases(t *testing.T) {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"stream at end of JSON", `{"messages":[],"stream":true}`, "/v1/chat/completions", true},
|
||||
{"stream with spaces", `{"stream" : true}`, "/v1/chat/completions", true},
|
||||
{"stream embedded in string value", `{"model":"stream:true"}`, "/v1/chat/completions", false},
|
||||
{"empty body", "", "/v1/chat/completions", false},
|
||||
{"stream at end of JSON", `{"messages":[],"stream":true}`, "/chat/completions", true},
|
||||
{"stream with spaces", `{"stream" : true}`, "/chat/completions", true},
|
||||
{"stream embedded in string value", `{"model":"stream:true"}`, "/chat/completions", false},
|
||||
{"empty body", "", "/chat/completions", false},
|
||||
{"stream true embeddings", `{"model":"text-emb","stream":true}`, "/v1/embeddings", false},
|
||||
}
|
||||
|
||||
@@ -721,7 +721,7 @@ func TestProxyHandler_HandleProxy_RouteEmptyBody_NoModel(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -744,35 +744,35 @@ func TestIsStreamRequest(t *testing.T) {
|
||||
name: "stream true",
|
||||
body: []byte(`{"model": "gpt-4", "stream": true}`),
|
||||
clientProtocol: "openai",
|
||||
nativePath: "/v1/chat/completions",
|
||||
nativePath: "/chat/completions",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "stream false",
|
||||
body: []byte(`{"model": "gpt-4", "stream": false}`),
|
||||
clientProtocol: "openai",
|
||||
nativePath: "/v1/chat/completions",
|
||||
nativePath: "/chat/completions",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no stream field",
|
||||
body: []byte(`{"model": "gpt-4"}`),
|
||||
clientProtocol: "openai",
|
||||
nativePath: "/v1/chat/completions",
|
||||
nativePath: "/chat/completions",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
body: []byte(`{invalid}`),
|
||||
clientProtocol: "openai",
|
||||
nativePath: "/v1/chat/completions",
|
||||
nativePath: "/chat/completions",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not chat endpoint",
|
||||
body: []byte(`{"model": "gpt-4", "stream": true}`),
|
||||
clientProtocol: "openai",
|
||||
nativePath: "/v1/models",
|
||||
nativePath: "/models",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
@@ -806,7 +806,7 @@ func TestProxyHandler_HandleProxy_Models_LocalAggregation(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -832,7 +832,7 @@ func TestProxyHandler_HandleProxy_ModelInfo_LocalQuery(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/openai/gpt-4"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models/openai/gpt-4", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models/openai/gpt-4", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -862,7 +862,7 @@ func TestProxyHandler_HandleProxy_Models_EmptySuffix_ForwardPassthrough(t *testi
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models/", nil)
|
||||
c.Request = httptest.NewRequest("GET", "/openai/models/", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -898,7 +898,7 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_UnifiedID(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// 客户端发送统一模型 ID
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -934,7 +934,7 @@ func TestProxyHandler_HandleProxy_CrossProtocol_NonStream_UnifiedID(t *testing.T
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// OpenAI 客户端使用统一模型 ID 路由到 Anthropic 供应商
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -980,7 +980,7 @@ data: {"type":"message_stop"}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -1016,7 +1016,7 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_Fidelity(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// 包含未知参数,验证 Smart Passthrough 保真性
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}],"custom_param":"should_be_preserved"}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}],"custom_param":"should_be_preserved"}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
@@ -1048,7 +1048,7 @@ func TestProxyHandler_HandleProxy_UnifiedID_ModelNotFound(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// 使用统一模型 ID 格式但模型不存在
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 404, w.Code)
|
||||
|
||||
@@ -77,7 +77,7 @@ func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server)
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// 代理路由
|
||||
r.Any("/:protocol/v1/*path", proxyHandler.HandleProxy)
|
||||
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||
|
||||
// 管理路由
|
||||
providers := r.Group("/api/providers")
|
||||
@@ -177,7 +177,7 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) {
|
||||
body, _ := json.Marshal(openaiReq)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestConversion_AnthropicToOpenAI_NonStream(t *testing.T) {
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
assert.Equal(t, "/v1/chat/completions", r.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", r.URL.Path)
|
||||
assert.Contains(t, r.Header.Get("Authorization"), "Bearer test-key")
|
||||
|
||||
resp := map[string]any{
|
||||
@@ -262,7 +262,7 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
|
||||
r, _, upstream := setupConversionTest(t)
|
||||
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/v1/chat/completions", r.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", r.URL.Path)
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
@@ -284,7 +284,7 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -364,7 +364,7 @@ func TestConversion_OpenAIToAnthropic_Stream(t *testing.T) {
|
||||
body, _ := json.Marshal(openaiReq)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -483,7 +483,7 @@ func TestConversion_ErrorResponse_Format(t *testing.T) {
|
||||
|
||||
// OpenAI 协议格式
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.True(t, w.Code >= 400)
|
||||
@@ -499,15 +499,15 @@ func TestConversion_OldRoutes_Return404(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
// Gin 路由不匹配返回 404
|
||||
assert.Equal(t, 404, w.Code)
|
||||
// Gin 路由匹配但协议不支持返回 400
|
||||
assert.Equal(t, 400, w.Code)
|
||||
|
||||
// 旧 Anthropic 路由
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("POST", "/v1/messages", strings.NewReader(`{"model":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, 404, w.Code)
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
|
||||
// ============ Provider Protocol 字段测试 ============
|
||||
|
||||
@@ -67,7 +67,7 @@ func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CORS())
|
||||
r.Any("/:protocol/v1/*path", proxyHandler.HandleProxy)
|
||||
r.Any("/:protocol/*path", proxyHandler.HandleProxy)
|
||||
|
||||
providers := r.Group("/api/providers")
|
||||
{
|
||||
@@ -150,7 +150,7 @@ func parseOpenAIStreamChunks(body string) []string {
|
||||
func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) {
|
||||
r, upstream := setupE2ETest(t)
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
assert.Equal(t, "/v1/chat/completions", req.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", req.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-e2e-001",
|
||||
@@ -177,7 +177,7 @@ func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) {
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -233,7 +233,7 @@ func TestE2E_OpenAI_NonStream_MultiTurn(t *testing.T) {
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -290,7 +290,7 @@ func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) {
|
||||
"tool_choice": "auto",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestE2E_OpenAI_NonStream_MaxTokens_Length(t *testing.T) {
|
||||
"max_tokens": 30,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -369,7 +369,7 @@ func TestE2E_OpenAI_NonStream_UsageWithReasoning(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestE2E_OpenAI_NonStream_Refusal(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "做坏事"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -453,7 +453,7 @@ func TestE2E_OpenAI_Stream_Text(t *testing.T) {
|
||||
"stream": true,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -507,7 +507,7 @@ func TestE2E_OpenAI_Stream_ToolCalls(t *testing.T) {
|
||||
"stream": true,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -546,7 +546,7 @@ func TestE2E_OpenAI_Stream_WithUsage(t *testing.T) {
|
||||
"stream": true,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -967,7 +967,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_RequestFormat(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -982,7 +982,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_RequestFormat(t *testing.T) {
|
||||
func TestE2E_CrossProtocol_AnthropicToOpenAI_RequestFormat(t *testing.T) {
|
||||
r, upstream := setupE2ETest(t)
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
assert.Equal(t, "/v1/chat/completions", req.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", req.URL.Path)
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var reqBody map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &reqBody))
|
||||
@@ -1050,7 +1050,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream(t *testing.T) {
|
||||
"stream": true,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1065,7 +1065,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream(t *testing.T) {
|
||||
func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream(t *testing.T) {
|
||||
r, upstream := setupE2ETest(t)
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
assert.Equal(t, "/v1/chat/completions", req.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", req.URL.Path)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher := w.(http.Flusher)
|
||||
@@ -1127,7 +1127,7 @@ func TestE2E_OpenAI_ErrorResponse(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1212,7 +1212,7 @@ func TestE2E_OpenAI_NonStream_ParallelToolCalls(t *testing.T) {
|
||||
"tool_choice": "auto",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1253,7 +1253,7 @@ func TestE2E_OpenAI_NonStream_StopSequence(t *testing.T) {
|
||||
"stop": []string{"5"},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1290,7 +1290,7 @@ func TestE2E_OpenAI_NonStream_ContentFilter(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "危险内容"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1569,7 +1569,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_NonStream_ToolCalls(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1588,7 +1588,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_NonStream_ToolCalls(t *testing.T) {
|
||||
func TestE2E_CrossProtocol_AnthropicToOpenAI_NonStream_Thinking(t *testing.T) {
|
||||
r, upstream := setupE2ETest(t)
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
assert.Equal(t, "/v1/chat/completions", req.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", req.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-cross-think", "object": "chat.completion", "model": "gpt-4", "created": time.Now().Unix(),
|
||||
@@ -1640,7 +1640,7 @@ func TestE2E_CrossProtocol_StopReasonMapping(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "长文"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1689,7 +1689,7 @@ func TestE2E_OpenAI_NonStream_AssistantWithToolResult(t *testing.T) {
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1740,7 +1740,7 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) {
|
||||
"stream": true,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
@@ -1753,7 +1753,7 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) {
|
||||
func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream_ToolCalls(t *testing.T) {
|
||||
r, upstream := setupE2ETest(t)
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
assert.Equal(t, "/v1/chat/completions", req.URL.Path)
|
||||
assert.Equal(t, "/chat/completions", req.URL.Path)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher := w.(http.Flusher)
|
||||
@@ -1816,7 +1816,7 @@ func TestE2E_OpenAI_Upstream5xx_ErrorPassthrough(t *testing.T) {
|
||||
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -49,3 +49,14 @@
|
||||
- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest
|
||||
- **THEN** SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
|
||||
- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse
|
||||
|
||||
### Requirement: Anthropic 端点保持 v1 层级
|
||||
|
||||
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以兼容 Claude Code。
|
||||
|
||||
#### Scenario: Claude Code 调用 Anthropic 端点
|
||||
|
||||
- **WHEN** Claude Code 发送请求到 `/anthropic/v1/messages`
|
||||
- **THEN** 网关 SHALL 正确处理请求
|
||||
- **THEN** nativePath SHALL 为 `/v1/messages`
|
||||
- **THEN** 最终上游 URL SHALL 为 `base_url + /v1/messages`
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
|
||||
#### Scenario: 成功的非流式请求
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求
|
||||
- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商
|
||||
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
|
||||
|
||||
#### Scenario: 成功的流式请求
|
||||
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带 `stream: true`
|
||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带 `stream: true`
|
||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
|
||||
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用
|
||||
- **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]`
|
||||
|
||||
@@ -340,3 +340,33 @@ Anthropic 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改
|
||||
|
||||
- **WHEN** 调用 `RewriteResponseModelName(body, "anthropic/claude-3-opus", InterfaceTypeChat)`
|
||||
- **THEN** SHALL 将响应体中 model 字段替换为 `"anthropic/claude-3-opus"`,其余字段原样保留
|
||||
|
||||
### Requirement: Anthropic 适配器保持 v1 前缀
|
||||
|
||||
Anthropic 适配器 SHALL 在路径检测和构建时保持 `/v1` 前缀。
|
||||
|
||||
#### Scenario: DetectInterfaceType 检测带 v1 的路径
|
||||
|
||||
- **WHEN** 调用 `DetectInterfaceType("/v1/messages")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypeChat`
|
||||
|
||||
- **WHEN** 调用 `DetectInterfaceType("/v1/models")`
|
||||
- **THEN** SHALL 返回 `InterfaceTypeModels`
|
||||
|
||||
#### Scenario: BuildUrl 返回带 v1 的路径
|
||||
|
||||
- **WHEN** 调用 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||
- **THEN** SHALL 返回 `/v1/messages`
|
||||
|
||||
- **WHEN** 调用 `BuildUrl(nativePath, InterfaceTypeModels)`
|
||||
- **THEN** SHALL 返回 `/v1/models`
|
||||
|
||||
#### Scenario: 模型详情路径识别
|
||||
|
||||
- **WHEN** 路径为 `/v1/models/anthropic/claude-3-opus`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||
|
||||
#### Scenario: 提取统一模型 ID
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/anthropic/claude-3-opus")`
|
||||
- **THEN** SHALL 返回 `"anthropic/claude-3-opus"`
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- `protocolName()` SHALL 返回 `"openai"`
|
||||
- `supportsPassthrough()` SHALL 返回 true
|
||||
- `buildHeaders(provider)` SHALL 构建 `Authorization: Bearer <api_key>` 和 `Content-Type: application/json`
|
||||
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射 URL 路径
|
||||
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射 URL 路径,不带 `/v1` 前缀
|
||||
- `supportsInterface()` SHALL 对 CHAT、MODELS、MODEL_INFO、EMBEDDINGS、RERANK 返回 true
|
||||
|
||||
#### Scenario: 认证 Header 构建
|
||||
@@ -25,11 +25,11 @@
|
||||
#### Scenario: URL 映射
|
||||
|
||||
- **WHEN** interfaceType == CHAT
|
||||
- **THEN** SHALL 映射为 `/v1/chat/completions`
|
||||
- **THEN** SHALL 映射为 `/chat/completions`
|
||||
- **WHEN** interfaceType == MODELS
|
||||
- **THEN** SHALL 映射为 `/v1/models`
|
||||
- **THEN** SHALL 映射为 `/models`
|
||||
- **WHEN** interfaceType == EMBEDDINGS
|
||||
- **THEN** SHALL 映射为 `/v1/embeddings`
|
||||
- **THEN** SHALL 映射为 `/embeddings`
|
||||
|
||||
### Requirement: OpenAI 请求解码(OpenAI → Canonical)
|
||||
|
||||
@@ -276,17 +276,17 @@ OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径
|
||||
|
||||
#### Scenario: 含斜杠的统一模型 ID 路径
|
||||
|
||||
- **WHEN** 路径为 `/v1/models/openai/gpt-4`
|
||||
- **WHEN** 路径为 `/models/openai/gpt-4`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||
|
||||
#### Scenario: 含多段斜杠的统一模型 ID 路径
|
||||
|
||||
- **WHEN** 路径为 `/v1/models/azure/accounts/org-123/models/gpt-4`
|
||||
- **WHEN** 路径为 `/models/azure/accounts/org-123/models/gpt-4`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||
|
||||
#### Scenario: 模型列表路径不受影响
|
||||
|
||||
- **WHEN** 路径为 `/v1/models`
|
||||
- **WHEN** 路径为 `/models`
|
||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
|
||||
|
||||
### Requirement: 提取统一模型 ID
|
||||
@@ -295,17 +295,17 @@ OpenAI 适配器 SHALL 从路径中提取统一模型 ID。
|
||||
|
||||
#### Scenario: 标准路径提取
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/openai/gpt-4")`
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models/openai/gpt-4")`
|
||||
- **THEN** SHALL 返回 `"openai/gpt-4"`
|
||||
|
||||
#### Scenario: 复杂路径提取
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")`
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")`
|
||||
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
|
||||
|
||||
#### Scenario: 非模型详情路径
|
||||
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models")`
|
||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models")`
|
||||
- **THEN** SHALL 返回错误
|
||||
|
||||
### Requirement: 从请求体提取 model
|
||||
|
||||
@@ -14,9 +14,10 @@ ProxyHandler SHALL 依赖 ConversionEngine、ProviderClient、RoutingService、S
|
||||
|
||||
#### Scenario: 从 URL 提取客户端协议
|
||||
|
||||
- **WHEN** 收到 `/{protocol}/v1/{path}` 格式的请求
|
||||
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
|
||||
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
|
||||
- **THEN** SHALL 剥离前缀得到 nativePath
|
||||
- **THEN** nativePath SHALL 不添加任何前缀,直接使用 path 参数
|
||||
|
||||
#### Scenario: 协议前缀必须是已注册协议
|
||||
|
||||
|
||||
Reference in New Issue
Block a user