diff --git a/backend/README.md b/backend/README.md index 7e8990f..c143ed9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` #### 模型管理 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 103eda4..c24f41c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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") diff --git a/backend/internal/conversion/engine_supplemental_test.go b/backend/internal/conversion/engine_supplemental_test.go index 4b3dd1e..eec0c16 100644 --- a/backend/internal/conversion/engine_supplemental_test.go +++ b/backend/internal/conversion/engine_supplemental_test.go @@ -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) diff --git a/backend/internal/conversion/engine_test.go b/backend/internal/conversion/engine_test.go index 37bba12..eede79a 100644 --- a/backend/internal/conversion/engine_test.go +++ b/backend/internal/conversion/engine_test.go @@ -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"}]}`), } diff --git a/backend/internal/conversion/openai/adapter.go b/backend/internal/conversion/openai/adapter.go index 78e57ac..c94955b 100644 --- a/backend/internal/conversion/openai/adapter.go +++ b/backend/internal/conversion/openai/adapter.go @@ -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") } diff --git a/backend/internal/conversion/openai/adapter_test.go b/backend/internal/conversion/openai/adapter_test.go index e692b85..7c831ac 100644 --- a/backend/internal/conversion/openai/adapter_test.go +++ b/backend/internal/conversion/openai/adapter_test.go @@ -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 { diff --git a/backend/internal/conversion/openai/adapter_unified_test.go b/backend/internal/conversion/openai/adapter_unified_test.go index 5f50e00..7d598bb 100644 --- a/backend/internal/conversion/openai/adapter_unified_test.go +++ b/backend/internal/conversion/openai/adapter_unified_test.go @@ -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 { diff --git a/backend/internal/handler/proxy_handler.go b/backend/internal/handler/proxy_handler.go index 7b96c89..939c4ca 100644 --- a/backend/internal/handler/proxy_handler.go +++ b/backend/internal/handler/proxy_handler.go @@ -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() diff --git a/backend/internal/handler/proxy_handler_test.go b/backend/internal/handler/proxy_handler_test.go index d0ee800..d411306 100644 --- a/backend/internal/handler/proxy_handler_test.go +++ b/backend/internal/handler/proxy_handler_test.go @@ -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) diff --git a/backend/tests/integration/conversion_test.go b/backend/tests/integration/conversion_test.go index b5707f6..dd09194 100644 --- a/backend/tests/integration/conversion_test.go +++ b/backend/tests/integration/conversion_test.go @@ -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 字段测试 ============ diff --git a/backend/tests/integration/e2e_conversion_test.go b/backend/tests/integration/e2e_conversion_test.go index cb27cc0..f8109d4 100644 --- a/backend/tests/integration/e2e_conversion_test.go +++ b/backend/tests/integration/e2e_conversion_test.go @@ -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) diff --git a/openspec/specs/anthropic-protocol-proxy/spec.md b/openspec/specs/anthropic-protocol-proxy/spec.md index 6711e86..ec59218 100644 --- a/openspec/specs/anthropic-protocol-proxy/spec.md +++ b/openspec/specs/anthropic-protocol-proxy/spec.md @@ -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` diff --git a/openspec/specs/openai-protocol-proxy/spec.md b/openspec/specs/openai-protocol-proxy/spec.md index 9ff2894..3b0e12f 100644 --- a/openspec/specs/openai-protocol-proxy/spec.md +++ b/openspec/specs/openai-protocol-proxy/spec.md @@ -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]` diff --git a/openspec/specs/protocol-adapter-anthropic/spec.md b/openspec/specs/protocol-adapter-anthropic/spec.md index acb7297..c125cc6 100644 --- a/openspec/specs/protocol-adapter-anthropic/spec.md +++ b/openspec/specs/protocol-adapter-anthropic/spec.md @@ -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"` diff --git a/openspec/specs/protocol-adapter-openai/spec.md b/openspec/specs/protocol-adapter-openai/spec.md index 7689ad8..01dd67d 100644 --- a/openspec/specs/protocol-adapter-openai/spec.md +++ b/openspec/specs/protocol-adapter-openai/spec.md @@ -11,7 +11,7 @@ - `protocolName()` SHALL 返回 `"openai"` - `supportsPassthrough()` SHALL 返回 true - `buildHeaders(provider)` SHALL 构建 `Authorization: Bearer ` 和 `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 diff --git a/openspec/specs/unified-proxy-handler/spec.md b/openspec/specs/unified-proxy-handler/spec.md index dc37fb6..a69d211 100644 --- a/openspec/specs/unified-proxy-handler/spec.md +++ b/openspec/specs/unified-proxy-handler/spec.md @@ -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: 协议前缀必须是已注册协议