1
0

Merge branch 'dev-openai-path-parse'

This commit is contained in:
2026-04-21 20:50:52 +08:00
16 changed files with 193 additions and 145 deletions

View File

@@ -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`
#### 模型管理

View File

@@ -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")

View File

@@ -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)

View File

@@ -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"}]}`),
}

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 字段测试 ============

View File

@@ -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)

View File

@@ -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`

View File

@@ -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]`

View File

@@ -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"`

View File

@@ -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

View File

@@ -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: 协议前缀必须是已注册协议