fix: 完善转换代理行为
This commit is contained in:
12
README.md
12
README.md
@@ -165,18 +165,18 @@ bun dev
|
|||||||
代理接口统一使用 `/{protocol}/*path` 路由格式,模型 ID 使用 `provider_id/model_name` 格式(如 `openai/gpt-4`)。同协议请求走 Smart Passthrough,最小化 JSON 改写并保持未改写字段的 JSON 内容和类型不变;跨协议请求走完整 decode/encode 转换。
|
代理接口统一使用 `/{protocol}/*path` 路由格式,模型 ID 使用 `provider_id/model_name` 格式(如 `openai/gpt-4`)。同协议请求走 Smart Passthrough,最小化 JSON 改写并保持未改写字段的 JSON 内容和类型不变;跨协议请求走完整 decode/encode 转换。
|
||||||
|
|
||||||
**OpenAI 协议**(`protocol=openai`):
|
**OpenAI 协议**(`protocol=openai`):
|
||||||
- `POST /openai/chat/completions` - 对话补全
|
- `POST /openai/v1/chat/completions` - 对话补全
|
||||||
- `GET /openai/models` - 模型列表(本地数据库聚合)
|
- `GET /openai/v1/models` - 模型列表(本地数据库聚合)
|
||||||
- `GET /openai/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
|
- `GET /openai/v1/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
|
||||||
- `POST /openai/embeddings` - 嵌入
|
- `POST /openai/v1/embeddings` - 嵌入
|
||||||
- `POST /openai/rerank` - 重排序
|
- `POST /openai/v1/rerank` - 重排序
|
||||||
|
|
||||||
**Anthropic 协议**(`protocol=anthropic`):
|
**Anthropic 协议**(`protocol=anthropic`):
|
||||||
- `POST /anthropic/v1/messages` - 消息对话
|
- `POST /anthropic/v1/messages` - 消息对话
|
||||||
- `GET /anthropic/v1/models` - 模型列表(本地数据库聚合)
|
- `GET /anthropic/v1/models` - 模型列表(本地数据库聚合)
|
||||||
- `GET /anthropic/v1/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
|
- `GET /anthropic/v1/models/{provider_id}/{model_name}` - 模型详情(本地数据库查询)
|
||||||
|
|
||||||
路径边界:网关只剥离第一段协议前缀,剩余路径保持协议原生形态交给 adapter。OpenAI adapter 接收 `/chat/completions`、`/models`、`/embeddings`、`/rerank`;Anthropic adapter 接收 `/v1/messages`、`/v1/models`。因此 OpenAI 供应商 `base_url` 配置到版本路径一级(如 `https://api.openai.com/v1`),Anthropic 供应商 `base_url` 配置到域名级(如 `https://api.anthropic.com`)。
|
路径边界:网关只剥离第一段协议前缀,剩余路径保持协议原生形态交给 adapter。OpenAI adapter 接收 `/v1/chat/completions`、`/v1/models`、`/v1/embeddings`、`/v1/rerank`,并在构建上游 URL 时去掉 `/v1`;Anthropic adapter 接收 `/v1/messages`、`/v1/models`。因此 OpenAI 供应商 `base_url` 配置到版本路径一级(如 `https://api.openai.com/v1`),Anthropic 供应商 `base_url` 配置到域名级(如 `https://api.anthropic.com`)。
|
||||||
|
|
||||||
代理错误边界:网关层错误统一返回 `{"error":"...","code":"..."}`,例如 `INVALID_JSON`、`MODEL_NOT_FOUND`、`CONVERSION_FAILED`、`UPSTREAM_UNAVAILABLE`。只要上游已经返回 HTTP 响应,非 2xx 的 status、过滤 hop-by-hop header 后的 headers 和 body 会直接透传,不包装为应用错误或协议错误。
|
代理错误边界:网关层错误统一返回 `{"error":"...","code":"..."}`,例如 `INVALID_JSON`、`MODEL_NOT_FOUND`、`CONVERSION_FAILED`、`UPSTREAM_UNAVAILABLE`。只要上游已经返回 HTTP 响应,非 2xx 的 status、过滤 hop-by-hop header 后的 headers 和 body 会直接透传,不包装为应用错误或协议错误。
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ AI 网关后端服务,提供统一的大模型 API 代理接口。
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- 支持 OpenAI 协议(`/openai/...`,例如 `/openai/chat/completions`)
|
- 支持 OpenAI 协议(`/openai/v1/...`,例如 `/openai/v1/chat/completions`)
|
||||||
- 支持 Anthropic 协议(`/anthropic/v1/...`)
|
- 支持 Anthropic 协议(`/anthropic/v1/...`)
|
||||||
- 支持 Hub-and-Spoke 跨协议双向转换(OpenAI ↔ Anthropic)
|
- 支持 Hub-and-Spoke 跨协议双向转换(OpenAI ↔ Anthropic)
|
||||||
- 同协议透传(跳过 Canonical 全量转换,保持协议语义)
|
- 同协议透传(跳过 Canonical 全量转换,保持协议语义)
|
||||||
@@ -463,15 +463,15 @@ goose -dir migrations sqlite3 ~/.nex/config.db up
|
|||||||
|
|
||||||
### 代理接口
|
### 代理接口
|
||||||
|
|
||||||
使用 `/{protocol}/*path` URL 前缀路由。网关只剥离第一段协议前缀,不统一添加或移除 `/v1`;剩余 path 是协议原生 nativePath,由对应 adapter 识别和组合上游 URL。
|
使用 `/{protocol}/*path` URL 前缀路由。网关只剥离第一段协议前缀,不在 Handler 中统一添加或移除 `/v1`;剩余 path 是协议原生 nativePath,由对应 adapter 识别和组合上游 URL。
|
||||||
|
|
||||||
#### OpenAI 协议
|
#### OpenAI 协议
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /openai/chat/completions
|
POST /openai/v1/chat/completions
|
||||||
GET /openai/models
|
GET /openai/v1/models
|
||||||
POST /openai/embeddings
|
POST /openai/v1/embeddings
|
||||||
POST /openai/rerank
|
POST /openai/v1/rerank
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Anthropic 协议
|
#### Anthropic 协议
|
||||||
@@ -486,7 +486,7 @@ GET /anthropic/v1/models
|
|||||||
**统一模型 ID**:代理请求中的 `model` 字段使用 `provider_id/model_name` 格式(如 `openai/gpt-4`),网关据此路由到对应供应商。同协议时自动改写为上游 `model_name`,跨协议时通过全量转换处理。
|
**统一模型 ID**:代理请求中的 `model` 字段使用 `provider_id/model_name` 格式(如 `openai/gpt-4`),网关据此路由到对应供应商。同协议时自动改写为上游 `model_name`,跨协议时通过全量转换处理。
|
||||||
|
|
||||||
**base_url 约定**:
|
**base_url 约定**:
|
||||||
- OpenAI 供应商配置到版本路径一级,例如 `https://api.openai.com/v1`。
|
- OpenAI 供应商配置到版本路径一级,例如 `https://api.openai.com/v1`;当客户端请求 `/openai/v1/chat/completions` 时,OpenAI adapter 会把 nativePath `/v1/chat/completions` 映射为上游 path `/chat/completions`,最终 URL 为 `https://api.openai.com/v1/chat/completions`。
|
||||||
- Anthropic 供应商配置到域名级,例如 `https://api.anthropic.com`。
|
- Anthropic 供应商配置到域名级,例如 `https://api.anthropic.com`。
|
||||||
|
|
||||||
**模型提取边界**:只有 adapter 明确适配的 Chat、Embeddings、Rerank 等接口会提取 `model` 并尝试统一模型 ID 路由。未知接口不做顶层 `model` 猜测,直接按无 model 透传。
|
**模型提取边界**:只有 adapter 明确适配的 Chat、Embeddings、Rerank 等接口会提取 `model` 并尝试统一模型 ID 路由。未知接口不做顶层 `model` 猜测,直接按无 model 透传。
|
||||||
@@ -522,7 +522,7 @@ GET /anthropic/v1/models
|
|||||||
- Anthropic 协议:配置到域名,不包含版本路径,如 `https://api.anthropic.com`
|
- Anthropic 协议:配置到域名,不包含版本路径,如 `https://api.anthropic.com`
|
||||||
|
|
||||||
**对外 URL 格式**:
|
**对外 URL 格式**:
|
||||||
- OpenAI 协议:`/{protocol}/{endpoint}`,如 `/openai/chat/completions`、`/openai/models`、`/openai/embeddings`
|
- OpenAI 协议:`/{protocol}/v1/{endpoint}`,如 `/openai/v1/chat/completions`、`/openai/v1/models`、`/openai/v1/embeddings`
|
||||||
- Anthropic 协议:`/{protocol}/v1/{endpoint}`,如 `/anthropic/v1/messages`、`/anthropic/v1/models`
|
- Anthropic 协议:`/{protocol}/v1/{endpoint}`,如 `/anthropic/v1/messages`、`/anthropic/v1/models`
|
||||||
|
|
||||||
#### 模型管理
|
#### 模型管理
|
||||||
|
|||||||
@@ -3,31 +3,60 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MB_ICONERROR = 0x10
|
mbIconError = 0x10
|
||||||
MB_ICONINFORMATION = 0x40
|
mbIconInformation = 0x40
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
user32 = syscall.NewLazyDLL("user32.dll")
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
procMessageBoxW = user32.NewProc("MessageBoxW")
|
procMessageBoxW = user32.NewProc("MessageBoxW")
|
||||||
|
callMessageBoxW = func(hwnd, text, caption, flags uintptr) (uintptr, error) {
|
||||||
|
ret, _, err := procMessageBoxW.Call(hwnd, text, caption, flags)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func showError(title, message string) {
|
func showError(title, message string) {
|
||||||
messageBox(title, message, MB_ICONERROR)
|
if err := messageBox(title, message, mbIconError); err != nil {
|
||||||
|
if zapLogger != nil {
|
||||||
|
zapLogger.Warn("显示错误对话框失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageBox(title, message string, flags uint) {
|
func messageBox(title, message string, flags uint) error {
|
||||||
titlePtr, _ := syscall.UTF16PtrFromString(title)
|
titlePtr, err := syscall.UTF16PtrFromString(title)
|
||||||
messagePtr, _ := syscall.UTF16PtrFromString(message)
|
if err != nil {
|
||||||
procMessageBoxW.Call(
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
messagePtr, err := syscall.UTF16PtrFromString(message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, callErr := callMessageBoxW(
|
||||||
0,
|
0,
|
||||||
uintptr(unsafe.Pointer(messagePtr)),
|
uintptr(unsafe.Pointer(messagePtr)),
|
||||||
uintptr(unsafe.Pointer(titlePtr)),
|
uintptr(unsafe.Pointer(titlePtr)),
|
||||||
uintptr(flags),
|
uintptr(flags),
|
||||||
)
|
)
|
||||||
|
if ret != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if callErr != nil && !errors.Is(callErr, syscall.Errno(0)) {
|
||||||
|
return callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("MessageBoxW 调用失败")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
|
func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler) {
|
||||||
r.Any("/v1/*path", proxyHandler.HandleProxy)
|
r.Any("/openai/*path", withProtocol("openai", proxyHandler.HandleProxy))
|
||||||
|
r.Any("/anthropic/*path", withProtocol("anthropic", proxyHandler.HandleProxy))
|
||||||
|
|
||||||
providers := r.Group("/api/providers")
|
providers := r.Group("/api/providers")
|
||||||
{
|
{
|
||||||
@@ -199,12 +200,26 @@ func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHand
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withProtocol(protocol string, next gin.HandlerFunc) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Params = append(c.Params, gin.Param{Key: "protocol", Value: protocol})
|
||||||
|
next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupStaticFiles(r *gin.Engine) {
|
func setupStaticFiles(r *gin.Engine) {
|
||||||
distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
|
distFS, err := frontendDistFS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
|
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
setupStaticFilesWithFS(r, distFS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frontendDistFS() (fs.FS, error) {
|
||||||
|
return fs.Sub(embedfs.FrontendDist, "frontend-dist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) {
|
||||||
getContentType := func(path string) string {
|
getContentType := func(path string) string {
|
||||||
if strings.HasSuffix(path, ".js") {
|
if strings.HasSuffix(path, ".js") {
|
||||||
return "application/javascript"
|
return "application/javascript"
|
||||||
@@ -250,7 +265,10 @@ func setupStaticFiles(r *gin.Engine) {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/api/") ||
|
if strings.HasPrefix(path, "/api/") ||
|
||||||
strings.HasPrefix(path, "/v1/") ||
|
strings.HasPrefix(path, "/openai/") ||
|
||||||
|
strings.HasPrefix(path, "/anthropic/") ||
|
||||||
|
path == "/openai" ||
|
||||||
|
path == "/anthropic" ||
|
||||||
strings.HasPrefix(path, "/health") {
|
strings.HasPrefix(path, "/health") {
|
||||||
c.JSON(404, gin.H{"error": "not found"})
|
c.JSON(404, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,13 +3,59 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMessageBoxW_WindowsOnly(t *testing.T) {
|
func withMessageBoxW(t *testing.T, fn func(hwnd, text, caption, flags uintptr) (uintptr, error)) {
|
||||||
messageBox("测试标题", "测试消息", MB_ICONINFORMATION)
|
t.Helper()
|
||||||
|
|
||||||
|
old := callMessageBoxW
|
||||||
|
callMessageBoxW = fn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
callMessageBoxW = old
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageBoxW_WindowsOnly_InvalidUTF16(t *testing.T) {
|
||||||
|
err := messageBox("bad\x00title", "测试消息", mbIconInformation)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("包含 NUL 字符时应该返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageBoxW_WindowsOnly_SuccessIgnoresLastError(t *testing.T) {
|
||||||
|
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
|
||||||
|
return 1, syscall.Errno(123)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := messageBox("测试标题", "测试消息", mbIconInformation); err != nil {
|
||||||
|
t.Fatalf("MessageBoxW 返回成功时应忽略 last error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageBoxW_WindowsOnly_FailureUsesReturnValue(t *testing.T) {
|
||||||
|
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
|
||||||
|
return 0, syscall.Errno(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := messageBox("测试标题", "测试消息", mbIconInformation)
|
||||||
|
if !errors.Is(err, syscall.Errno(5)) {
|
||||||
|
t.Fatalf("MessageBoxW 返回 0 时应返回调用错误: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowError_WindowsBranch(t *testing.T) {
|
func TestShowError_WindowsBranch(t *testing.T) {
|
||||||
|
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
|
||||||
|
return 0, syscall.Errno(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("showError 不应因 MessageBoxW 失败而 panic: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
showError("测试错误", "这是一条测试错误消息")
|
showError("测试错误", "这是一条测试错误消息")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ const (
|
|||||||
appName = "Nex"
|
appName = "Nex"
|
||||||
appTooltip = appName
|
appTooltip = appName
|
||||||
appDescription = "AI Gateway - 统一的大模型 API 网关"
|
appDescription = "AI Gateway - 统一的大模型 API 网关"
|
||||||
appWebsite = "https://github.com/nex/gateway"
|
// #nosec G101 -- 项目官网地址不是凭据
|
||||||
|
appWebsite = "https://github.com/nex/gateway"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestCheckPortAvailable(t *testing.T) {
|
|||||||
func TestCheckPortOccupied(t *testing.T) {
|
func TestCheckPortOccupied(t *testing.T) {
|
||||||
port := 19827
|
port := 19827
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", ":19827")
|
listener, err := net.Listen("tcp", ":19827") //nolint:gosec // 需要验证 checkPortAvailable 对通配地址占用的检测行为
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("无法启动测试服务器: %v", err)
|
t.Fatalf("无法启动测试服务器: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,25 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"nex/embedfs"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetupStaticFiles(t *testing.T) {
|
func TestSetupStaticFiles(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
distFS, err := fs.Sub(embedfs.FrontendDist, "frontend-dist")
|
distFS, err := frontendDistFS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("跳过测试: 前端资源未构建: %v", err)
|
t.Skipf("跳过测试: 前端资源未构建: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
getContentType := func(path string) string {
|
|
||||||
if strings.HasSuffix(path, ".js") {
|
|
||||||
return "application/javascript"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".css") {
|
|
||||||
return "text/css"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".svg") {
|
|
||||||
return "image/svg+xml"
|
|
||||||
}
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.GET("/assets/*filepath", func(c *gin.Context) {
|
setupStaticFilesWithFS(r, distFS)
|
||||||
filepath := c.Param("filepath")
|
|
||||||
data, err := fs.ReadFile(distFS, "assets"+filepath)
|
|
||||||
if err != nil {
|
|
||||||
c.Status(404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(200, getContentType(filepath), data)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.GET("/favicon.svg", func(c *gin.Context) {
|
|
||||||
data, err := fs.ReadFile(distFS, "favicon.svg")
|
|
||||||
if err != nil {
|
|
||||||
c.Status(404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(200, "image/svg+xml", data)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.NoRoute(func(c *gin.Context) {
|
|
||||||
path := c.Request.URL.Path
|
|
||||||
if strings.HasPrefix(path, "/api/") ||
|
|
||||||
strings.HasPrefix(path, "/v1/") ||
|
|
||||||
strings.HasPrefix(path, "/health") {
|
|
||||||
c.JSON(404, gin.H{"error": "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := fs.ReadFile(distFS, "index.html")
|
|
||||||
if err != nil {
|
|
||||||
c.Status(500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(200, "text/html; charset=utf-8", data)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("API 404", func(t *testing.T) {
|
t.Run("API 404", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
@@ -79,6 +31,32 @@ func TestSetupStaticFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("OpenAI proxy prefix 404", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/openai/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("期望状态码 404, 实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "not found") {
|
||||||
|
t.Errorf("期望返回 API 风格错误,实际 %s", w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Anthropic proxy prefix 404", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/anthropic/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("期望状态码 404, 实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "not found") {
|
||||||
|
t.Errorf("期望返回 API 风格错误,实际 %s", w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("SPA fallback", func(t *testing.T) {
|
t.Run("SPA fallback", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/providers", nil)
|
req := httptest.NewRequest("GET", "/providers", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -121,3 +99,115 @@ func TestSetupStaticFiles(t *testing.T) {
|
|||||||
|
|
||||||
t.Log("静态文件服务测试通过")
|
t.Log("静态文件服务测试通过")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
distFS, err := frontendDistFS()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("跳过测试: 前端资源未构建: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
|
||||||
|
var gotProtocol string
|
||||||
|
var gotPath string
|
||||||
|
r.Any("/openai/*path", withProtocol("openai", func(c *gin.Context) {
|
||||||
|
gotProtocol = c.Param("protocol")
|
||||||
|
gotPath = c.Param("path")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
|
||||||
|
}))
|
||||||
|
r.Any("/anthropic/*path", withProtocol("anthropic", func(c *gin.Context) {
|
||||||
|
gotProtocol = c.Param("protocol")
|
||||||
|
gotPath = c.Param("path")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
|
||||||
|
}))
|
||||||
|
setupStaticFilesWithFS(r, distFS)
|
||||||
|
|
||||||
|
t.Run("OpenAI route enters proxy handler wrapper", func(t *testing.T) {
|
||||||
|
gotProtocol = ""
|
||||||
|
gotPath = ""
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("期望状态码 200, 实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
if gotProtocol != "openai" {
|
||||||
|
t.Errorf("期望 protocol=openai, 实际 %s", gotProtocol)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/chat/completions" {
|
||||||
|
t.Errorf("期望 path=/v1/chat/completions, 实际 %s", gotPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Anthropic route enters proxy handler wrapper", func(t *testing.T) {
|
||||||
|
gotProtocol = ""
|
||||||
|
gotPath = ""
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/anthropic/v1/messages", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("期望状态码 200, 实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
if gotProtocol != "anthropic" {
|
||||||
|
t.Errorf("期望 protocol=anthropic, 实际 %s", gotProtocol)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/messages" {
|
||||||
|
t.Errorf("期望 path=/v1/messages, 实际 %s", gotPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Static assets are not hijacked", func(t *testing.T) {
|
||||||
|
gotProtocol = ""
|
||||||
|
gotPath = ""
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/assets/test.js", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if gotProtocol != "" || gotPath != "" {
|
||||||
|
t.Errorf("静态资源不应进入代理包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
|
||||||
|
}
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
|
||||||
|
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("期望静态资源返回 200 或 404, 实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SPA path keeps fallback", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/providers", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("期望状态码 200, 实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Header().Get("Content-Type"), "text/html") {
|
||||||
|
t.Errorf("期望返回 HTML,实际 %s", w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unknown proxy-like path does not return index html", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/openai/unknown", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("显式代理路由应进入代理包装器,实际状态码 %d", w.Code)
|
||||||
|
}
|
||||||
|
if gotProtocol != "openai" || gotPath != "/unknown" {
|
||||||
|
t.Errorf("期望 unknown 代理路径进入 openai 包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -288,19 +288,33 @@ func TestDetectInterfaceType_NonExistentProtocol(t *testing.T) {
|
|||||||
func TestConvertHttpRequest_Passthrough(t *testing.T) {
|
func TestConvertHttpRequest_Passthrough(t *testing.T) {
|
||||||
registry := NewMemoryRegistry()
|
registry := NewMemoryRegistry()
|
||||||
engine := NewConversionEngine(registry, zap.NewNop())
|
engine := NewConversionEngine(registry, zap.NewNop())
|
||||||
_ = engine.RegisterAdapter(newMockAdapter("openai", true))
|
openaiAdapter := &buildURLMockAdapter{
|
||||||
|
mockProtocolAdapter: newMockAdapter("openai", true),
|
||||||
|
buildURLFn: func(nativePath string, interfaceType InterfaceType) string {
|
||||||
|
if interfaceType == InterfaceTypeChat {
|
||||||
|
return "/chat/completions"
|
||||||
|
}
|
||||||
|
return nativePath
|
||||||
|
},
|
||||||
|
}
|
||||||
|
openaiAdapter.ifaceType = InterfaceTypeChat
|
||||||
|
openaiAdapter.supportsIface[InterfaceTypeChat] = true
|
||||||
|
openaiAdapter.rewriteReqFn = func(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error) {
|
||||||
|
return []byte(`{"model":"` + newModel + `","messages":[{"role":"user","content":"hi"}]}`), nil
|
||||||
|
}
|
||||||
|
_ = engine.RegisterAdapter(openaiAdapter)
|
||||||
|
|
||||||
provider := NewTargetProvider("https://api.openai.com/v1", "sk-test", "gpt-4")
|
provider := NewTargetProvider("https://api.openai.com/v1", "sk-test", "gpt-4")
|
||||||
spec := HTTPRequestSpec{
|
spec := HTTPRequestSpec{
|
||||||
URL: "/chat/completions",
|
URL: "/v1/chat/completions",
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
Body: []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`),
|
Body: []byte(`{"model":"openai/gpt-4","messages":[{"role":"user","content":"hi"}]}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := engine.ConvertHttpRequest(spec, "openai", "openai", provider)
|
result, err := engine.ConvertHttpRequest(spec, "openai", "openai", provider)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://api.openai.com/v1/chat/completions", result.URL)
|
assert.Equal(t, "https://api.openai.com/v1/chat/completions", result.URL)
|
||||||
assert.Equal(t, spec.Body, result.Body)
|
assert.JSONEq(t, `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`, string(result.Body))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
|
func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
|
||||||
@@ -335,6 +349,77 @@ func TestConvertHttpRequest_CrossProtocol(t *testing.T) {
|
|||||||
assert.NotNil(t, result.Body)
|
assert.NotNil(t, result.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertHttpRequest_UsesProviderAdapterBuildURL(t *testing.T) {
|
||||||
|
registry := NewMemoryRegistry()
|
||||||
|
engine := NewConversionEngine(registry, zap.NewNop())
|
||||||
|
openaiAdapter := &buildURLMockAdapter{
|
||||||
|
mockProtocolAdapter: newMockAdapter("openai", true),
|
||||||
|
buildURLFn: func(nativePath string, interfaceType InterfaceType) string {
|
||||||
|
if interfaceType == InterfaceTypeChat {
|
||||||
|
return "/chat/completions"
|
||||||
|
}
|
||||||
|
return nativePath
|
||||||
|
},
|
||||||
|
}
|
||||||
|
openaiAdapter.ifaceType = InterfaceTypeChat
|
||||||
|
openaiAdapter.supportsIface[InterfaceTypeChat] = true
|
||||||
|
openaiAdapter.rewriteReqFn = func(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error) {
|
||||||
|
return []byte(`{"model":"` + newModel + `"}`), nil
|
||||||
|
}
|
||||||
|
require.NoError(t, registry.Register(openaiAdapter))
|
||||||
|
|
||||||
|
anthropicAdapter := &buildURLMockAdapter{
|
||||||
|
mockProtocolAdapter: newMockAdapter("anthropic", false),
|
||||||
|
buildURLFn: func(nativePath string, interfaceType InterfaceType) string {
|
||||||
|
if interfaceType == InterfaceTypeChat {
|
||||||
|
return "/v1/messages"
|
||||||
|
}
|
||||||
|
return nativePath
|
||||||
|
},
|
||||||
|
}
|
||||||
|
anthropicAdapter.ifaceType = InterfaceTypeChat
|
||||||
|
anthropicAdapter.supportsIface[InterfaceTypeChat] = true
|
||||||
|
require.NoError(t, registry.Register(anthropicAdapter))
|
||||||
|
|
||||||
|
t.Run("OpenAI to Anthropic", func(t *testing.T) {
|
||||||
|
provider := NewTargetProvider("https://api.anthropic.com", "key", "claude-3")
|
||||||
|
spec := HTTPRequestSpec{
|
||||||
|
URL: "/v1/chat/completions",
|
||||||
|
Method: "POST",
|
||||||
|
Body: []byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"max_tokens":16}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.ConvertHttpRequest(spec, "openai", "anthropic", provider)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "https://api.anthropic.com/v1/messages", result.URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Anthropic to OpenAI", func(t *testing.T) {
|
||||||
|
provider := NewTargetProvider("https://api.openai.com/v1", "key", "gpt-4")
|
||||||
|
spec := HTTPRequestSpec{
|
||||||
|
URL: "/v1/messages",
|
||||||
|
Method: "POST",
|
||||||
|
Body: []byte(`{"model":"p1/claude-3","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.ConvertHttpRequest(spec, "anthropic", "openai", provider)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "https://api.openai.com/v1/chat/completions", result.URL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type buildURLMockAdapter struct {
|
||||||
|
*mockProtocolAdapter
|
||||||
|
buildURLFn func(string, InterfaceType) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *buildURLMockAdapter) BuildUrl(nativePath string, interfaceType InterfaceType) string {
|
||||||
|
if m.buildURLFn != nil {
|
||||||
|
return m.buildURLFn(nativePath, interfaceType)
|
||||||
|
}
|
||||||
|
return m.mockProtocolAdapter.BuildUrl(nativePath, interfaceType)
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertHttpResponse_Passthrough(t *testing.T) {
|
func TestConvertHttpResponse_Passthrough(t *testing.T) {
|
||||||
registry := NewMemoryRegistry()
|
registry := NewMemoryRegistry()
|
||||||
engine := NewConversionEngine(registry, zap.NewNop())
|
engine := NewConversionEngine(registry, zap.NewNop())
|
||||||
|
|||||||
@@ -29,27 +29,27 @@ func (a *Adapter) SupportsPassthrough() bool { return true }
|
|||||||
// DetectInterfaceType 根据路径检测接口类型
|
// DetectInterfaceType 根据路径检测接口类型
|
||||||
func (a *Adapter) DetectInterfaceType(nativePath string) conversion.InterfaceType {
|
func (a *Adapter) DetectInterfaceType(nativePath string) conversion.InterfaceType {
|
||||||
switch {
|
switch {
|
||||||
case nativePath == "/chat/completions":
|
case nativePath == "/v1/chat/completions":
|
||||||
return conversion.InterfaceTypeChat
|
return conversion.InterfaceTypeChat
|
||||||
case nativePath == "/models":
|
case nativePath == "/v1/models":
|
||||||
return conversion.InterfaceTypeModels
|
return conversion.InterfaceTypeModels
|
||||||
case isModelInfoPath(nativePath):
|
case isModelInfoPath(nativePath):
|
||||||
return conversion.InterfaceTypeModelInfo
|
return conversion.InterfaceTypeModelInfo
|
||||||
case nativePath == "/embeddings":
|
case nativePath == "/v1/embeddings":
|
||||||
return conversion.InterfaceTypeEmbeddings
|
return conversion.InterfaceTypeEmbeddings
|
||||||
case nativePath == "/rerank":
|
case nativePath == "/v1/rerank":
|
||||||
return conversion.InterfaceTypeRerank
|
return conversion.InterfaceTypeRerank
|
||||||
default:
|
default:
|
||||||
return conversion.InterfaceTypePassthrough
|
return conversion.InterfaceTypePassthrough
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isModelInfoPath 判断是否为模型详情路径(/models/{id},允许 id 含 /)
|
// isModelInfoPath 判断是否为模型详情路径(/v1/models/{id},允许 id 含 /)
|
||||||
func isModelInfoPath(path string) bool {
|
func isModelInfoPath(path string) bool {
|
||||||
if !strings.HasPrefix(path, "/models/") {
|
if !strings.HasPrefix(path, "/v1/models/") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
suffix := path[len("/models/"):]
|
suffix := path[len("/v1/models/"):]
|
||||||
return suffix != ""
|
return suffix != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +60,11 @@ func (a *Adapter) BuildUrl(nativePath string, interfaceType conversion.Interface
|
|||||||
return "/chat/completions"
|
return "/chat/completions"
|
||||||
case conversion.InterfaceTypeModels:
|
case conversion.InterfaceTypeModels:
|
||||||
return "/models"
|
return "/models"
|
||||||
|
case conversion.InterfaceTypeModelInfo:
|
||||||
|
if modelID, err := a.ExtractUnifiedModelID(nativePath); err == nil {
|
||||||
|
return "/models/" + modelID
|
||||||
|
}
|
||||||
|
return nativePath
|
||||||
case conversion.InterfaceTypeEmbeddings:
|
case conversion.InterfaceTypeEmbeddings:
|
||||||
return "/embeddings"
|
return "/embeddings"
|
||||||
case conversion.InterfaceTypeRerank:
|
case conversion.InterfaceTypeRerank:
|
||||||
@@ -221,12 +226,12 @@ func (a *Adapter) EncodeRerankResponse(resp *canonical.CanonicalRerankResponse)
|
|||||||
return encodeRerankResponse(resp)
|
return encodeRerankResponse(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractUnifiedModelID 从路径中提取统一模型 ID(/models/{provider_id}/{model_name})
|
// ExtractUnifiedModelID 从路径中提取统一模型 ID(/v1/models/{provider_id}/{model_name})
|
||||||
func (a *Adapter) ExtractUnifiedModelID(nativePath string) (string, error) {
|
func (a *Adapter) ExtractUnifiedModelID(nativePath string) (string, error) {
|
||||||
if !strings.HasPrefix(nativePath, "/models/") {
|
if !strings.HasPrefix(nativePath, "/v1/models/") {
|
||||||
return "", fmt.Errorf("不是模型详情路径: %s", nativePath)
|
return "", fmt.Errorf("不是模型详情路径: %s", nativePath)
|
||||||
}
|
}
|
||||||
suffix := nativePath[len("/models/"):]
|
suffix := nativePath[len("/v1/models/"):]
|
||||||
if suffix == "" {
|
if suffix == "" {
|
||||||
return "", fmt.Errorf("路径缺少模型 ID")
|
return "", fmt.Errorf("路径缺少模型 ID")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ func TestAdapter_DetectInterfaceType(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
expected conversion.InterfaceType
|
expected conversion.InterfaceType
|
||||||
}{
|
}{
|
||||||
{"聊天补全", "/chat/completions", conversion.InterfaceTypeChat},
|
{"聊天补全", "/v1/chat/completions", conversion.InterfaceTypeChat},
|
||||||
{"模型列表", "/models", conversion.InterfaceTypeModels},
|
{"模型列表", "/v1/models", conversion.InterfaceTypeModels},
|
||||||
{"模型详情", "/models/gpt-4", conversion.InterfaceTypeModelInfo},
|
{"模型详情", "/v1/models/openai/gpt-4", conversion.InterfaceTypeModelInfo},
|
||||||
{"嵌入接口", "/embeddings", conversion.InterfaceTypeEmbeddings},
|
{"嵌入接口", "/v1/embeddings", conversion.InterfaceTypeEmbeddings},
|
||||||
{"重排序接口", "/rerank", conversion.InterfaceTypeRerank},
|
{"重排序接口", "/v1/rerank", conversion.InterfaceTypeRerank},
|
||||||
{"未知路径", "/unknown", conversion.InterfaceTypePassthrough},
|
{"未知路径", "/unknown", conversion.InterfaceTypePassthrough},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,20 +44,18 @@ func TestAdapter_DetectInterfaceType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdapter_APIReferenceNativePaths(t *testing.T) {
|
func TestAdapter_OldPathsBecomePassthrough(t *testing.T) {
|
||||||
a := NewAdapter()
|
a := NewAdapter()
|
||||||
|
|
||||||
// docs/api_reference/openai, excluding responses, defines paths without /v1.
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string
|
path string
|
||||||
expected conversion.InterfaceType
|
expected conversion.InterfaceType
|
||||||
}{
|
}{
|
||||||
{"/chat/completions", conversion.InterfaceTypeChat},
|
{"/chat/completions", conversion.InterfaceTypePassthrough},
|
||||||
{"/models", conversion.InterfaceTypeModels},
|
{"/models", conversion.InterfaceTypePassthrough},
|
||||||
{"/models/gpt-4.1", conversion.InterfaceTypeModelInfo},
|
{"/models/gpt-4.1", conversion.InterfaceTypePassthrough},
|
||||||
{"/embeddings", conversion.InterfaceTypeEmbeddings},
|
{"/embeddings", conversion.InterfaceTypePassthrough},
|
||||||
{"/rerank", conversion.InterfaceTypeRerank},
|
{"/rerank", conversion.InterfaceTypePassthrough},
|
||||||
{"/v1/chat/completions", conversion.InterfaceTypePassthrough},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -76,10 +74,12 @@ func TestAdapter_BuildUrl(t *testing.T) {
|
|||||||
interfaceType conversion.InterfaceType
|
interfaceType conversion.InterfaceType
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{"聊天", "/chat/completions", conversion.InterfaceTypeChat, "/chat/completions"},
|
{"聊天", "/v1/chat/completions", conversion.InterfaceTypeChat, "/chat/completions"},
|
||||||
{"模型", "/models", conversion.InterfaceTypeModels, "/models"},
|
{"模型", "/v1/models", conversion.InterfaceTypeModels, "/models"},
|
||||||
{"嵌入", "/embeddings", conversion.InterfaceTypeEmbeddings, "/embeddings"},
|
{"模型详情", "/v1/models/openai/gpt-4", conversion.InterfaceTypeModelInfo, "/models/openai/gpt-4"},
|
||||||
{"重排序", "/rerank", conversion.InterfaceTypeRerank, "/rerank"},
|
{"复杂模型详情", "/v1/models/azure/accounts/org/models/gpt-4", conversion.InterfaceTypeModelInfo, "/models/azure/accounts/org/models/gpt-4"},
|
||||||
|
{"嵌入", "/v1/embeddings", conversion.InterfaceTypeEmbeddings, "/embeddings"},
|
||||||
|
{"重排序", "/v1/rerank", conversion.InterfaceTypeRerank, "/rerank"},
|
||||||
{"默认透传", "/other", conversion.InterfaceTypePassthrough, "/other"},
|
{"默认透传", "/other", conversion.InterfaceTypePassthrough, "/other"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +141,12 @@ func TestIsModelInfoPath(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{"model_info", "/models/gpt-4", true},
|
{"model_info", "/v1/models/openai/gpt-4", true},
|
||||||
{"model_info_with_dots", "/models/gpt-4.1-preview", true},
|
{"model_info_with_dots", "/v1/models/openai/gpt-4.1-preview", true},
|
||||||
{"models_list", "/models", false},
|
{"models_list", "/v1/models", false},
|
||||||
{"nested_path", "/models/gpt-4/versions", true},
|
{"nested_path", "/v1/models/azure/accounts/org-123/models/gpt-4", true},
|
||||||
{"empty_suffix", "/models/", false},
|
{"empty_suffix", "/v1/models/", false},
|
||||||
{"unrelated", "/chat/completions", false},
|
{"unrelated", "/v1/chat/completions", false},
|
||||||
{"partial_prefix", "/model", false},
|
{"partial_prefix", "/model", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +157,27 @@ func TestIsModelInfoPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ExtractUnifiedModelID(t *testing.T) {
|
||||||
|
a := NewAdapter()
|
||||||
|
|
||||||
|
t.Run("标准路径", func(t *testing.T) {
|
||||||
|
modelID, err := a.ExtractUnifiedModelID("/v1/models/openai/gpt-4")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "openai/gpt-4", modelID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("复杂路径", func(t *testing.T) {
|
||||||
|
modelID, err := a.ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "azure/accounts/org/models/gpt-4", modelID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("非模型详情路径报错", func(t *testing.T) {
|
||||||
|
_, err := a.ExtractUnifiedModelID("/v1/models")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdapter_EncodeError_InvalidInput(t *testing.T) {
|
func TestAdapter_EncodeError_InvalidInput(t *testing.T) {
|
||||||
a := NewAdapter()
|
a := NewAdapter()
|
||||||
convErr := conversion.NewConversionError(conversion.ErrorCodeInvalidInput, "参数无效")
|
convErr := conversion.NewConversionError(conversion.ErrorCodeInvalidInput, "参数无效")
|
||||||
|
|||||||
@@ -18,35 +18,35 @@ func TestExtractUnifiedModelID(t *testing.T) {
|
|||||||
a := NewAdapter()
|
a := NewAdapter()
|
||||||
|
|
||||||
t.Run("standard_path", func(t *testing.T) {
|
t.Run("standard_path", func(t *testing.T) {
|
||||||
id, err := a.ExtractUnifiedModelID("/models/openai/gpt-4")
|
id, err := a.ExtractUnifiedModelID("/v1/models/openai/gpt-4")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "openai/gpt-4", id)
|
assert.Equal(t, "openai/gpt-4", id)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("multi_segment_path", func(t *testing.T) {
|
t.Run("multi_segment_path", func(t *testing.T) {
|
||||||
id, err := a.ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")
|
id, err := a.ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "azure/accounts/org/models/gpt-4", id)
|
assert.Equal(t, "azure/accounts/org/models/gpt-4", id)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("single_segment", func(t *testing.T) {
|
t.Run("single_segment", func(t *testing.T) {
|
||||||
id, err := a.ExtractUnifiedModelID("/models/gpt-4")
|
id, err := a.ExtractUnifiedModelID("/v1/models/gpt-4")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "gpt-4", id)
|
assert.Equal(t, "gpt-4", id)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("non_model_path", func(t *testing.T) {
|
t.Run("non_model_path", func(t *testing.T) {
|
||||||
_, err := a.ExtractUnifiedModelID("/chat/completions")
|
_, err := a.ExtractUnifiedModelID("/v1/chat/completions")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty_suffix", func(t *testing.T) {
|
t.Run("empty_suffix", func(t *testing.T) {
|
||||||
_, err := a.ExtractUnifiedModelID("/models/")
|
_, err := a.ExtractUnifiedModelID("/v1/models/")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("models_list_no_slash", func(t *testing.T) {
|
t.Run("models_list_no_slash", func(t *testing.T) {
|
||||||
_, err := a.ExtractUnifiedModelID("/models")
|
_, err := a.ExtractUnifiedModelID("/v1/models")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -344,12 +344,12 @@ func TestIsModelInfoPath_UnifiedModelID(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{"simple_model_id", "/models/gpt-4", true},
|
{"simple_model_id", "/v1/models/gpt-4", true},
|
||||||
{"unified_model_id_with_slash", "/models/openai/gpt-4", true},
|
{"unified_model_id_with_slash", "/v1/models/openai/gpt-4", true},
|
||||||
{"models_list", "/models", false},
|
{"models_list", "/v1/models", false},
|
||||||
{"models_list_trailing_slash", "/models/", false},
|
{"models_list_trailing_slash", "/v1/models/", false},
|
||||||
{"chat_completions", "/chat/completions", false},
|
{"chat_completions", "/v1/chat/completions", false},
|
||||||
{"deeply_nested", "/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
|
{"deeply_nested", "/v1/models/azure/eastus/deployments/my-dept/models/gpt-4", true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ func TestProxyHandler_HandleProxy_NonStreamSuccess(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -118,8 +118,8 @@ func TestProxyHandler_HandleProxy_RoutingError_WithBody(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 404, w.Code)
|
assert.Equal(t, 404, w.Code)
|
||||||
@@ -146,8 +146,8 @@ func TestProxyHandler_HandleProxy_ConversionError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 502, w.Code)
|
assert.Equal(t, 502, w.Code)
|
||||||
@@ -174,8 +174,8 @@ func TestProxyHandler_HandleProxy_ClientSendError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 502, w.Code)
|
assert.Equal(t, 502, w.Code)
|
||||||
@@ -211,8 +211,8 @@ func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -241,8 +241,8 @@ func TestProxyHandler_HandleProxy_StreamError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 502, w.Code)
|
assert.Equal(t, 502, w.Code)
|
||||||
@@ -266,8 +266,8 @@ func TestProxyHandler_ForwardPassthrough_GET(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -287,7 +287,7 @@ func TestProxyHandler_ForwardPassthrough_UnsupportedProtocol(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "unknown"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "unknown"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/unknown/models", nil)
|
c.Request = httptest.NewRequest("GET", "/unknown/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
@@ -309,8 +309,8 @@ func TestProxyHandler_ForwardPassthrough_NoProviders(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -352,8 +352,8 @@ func TestProxyHandler_HandleProxy_ProviderProtocolDefault(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -449,8 +449,8 @@ func TestProxyHandler_HandleProxy_EmptyBody(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -483,8 +483,8 @@ func TestProxyHandler_HandleStream_MidStreamError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -521,8 +521,8 @@ func TestProxyHandler_HandleStream_FlushOutput(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -555,8 +555,8 @@ func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 500, w.Code)
|
assert.Equal(t, 500, w.Code)
|
||||||
@@ -582,8 +582,8 @@ func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 500, w.Code)
|
assert.Equal(t, 500, w.Code)
|
||||||
@@ -617,8 +617,8 @@ func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 500, w.Code)
|
assert.Equal(t, 500, w.Code)
|
||||||
@@ -649,8 +649,8 @@ func TestProxyHandler_HandleNonStream_ResponseHeaders(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -681,8 +681,8 @@ func TestProxyHandler_ForwardPassthrough_CrossProtocol(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -705,8 +705,8 @@ func TestProxyHandler_ForwardPassthrough_NoBody_NoModel(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -729,10 +729,10 @@ func TestIsStreamRequest_EdgeCases(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{"stream at end of JSON", `{"messages":[],"stream":true}`, "/chat/completions", true},
|
{"stream at end of JSON", `{"messages":[],"stream":true}`, "/v1/chat/completions", true},
|
||||||
{"stream with spaces", `{"stream" : true}`, "/chat/completions", true},
|
{"stream with spaces", `{"stream" : true}`, "/v1/chat/completions", true},
|
||||||
{"stream embedded in string value", `{"model":"stream:true"}`, "/chat/completions", false},
|
{"stream embedded in string value", `{"model":"stream:true"}`, "/v1/chat/completions", false},
|
||||||
{"empty body", "", "/chat/completions", false},
|
{"empty body", "", "/v1/chat/completions", false},
|
||||||
{"stream true embeddings", `{"model":"text-emb","stream":true}`, "/v1/embeddings", false},
|
{"stream true embeddings", `{"model":"text-emb","stream":true}`, "/v1/embeddings", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,8 +781,8 @@ func TestProxyHandler_HandleProxy_RouteEmptyBody_NoModel(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -805,35 +805,35 @@ func TestIsStreamRequest(t *testing.T) {
|
|||||||
name: "stream true",
|
name: "stream true",
|
||||||
body: []byte(`{"model": "gpt-4", "stream": true}`),
|
body: []byte(`{"model": "gpt-4", "stream": true}`),
|
||||||
clientProtocol: "openai",
|
clientProtocol: "openai",
|
||||||
nativePath: "/chat/completions",
|
nativePath: "/v1/chat/completions",
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "stream false",
|
name: "stream false",
|
||||||
body: []byte(`{"model": "gpt-4", "stream": false}`),
|
body: []byte(`{"model": "gpt-4", "stream": false}`),
|
||||||
clientProtocol: "openai",
|
clientProtocol: "openai",
|
||||||
nativePath: "/chat/completions",
|
nativePath: "/v1/chat/completions",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no stream field",
|
name: "no stream field",
|
||||||
body: []byte(`{"model": "gpt-4"}`),
|
body: []byte(`{"model": "gpt-4"}`),
|
||||||
clientProtocol: "openai",
|
clientProtocol: "openai",
|
||||||
nativePath: "/chat/completions",
|
nativePath: "/v1/chat/completions",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid json",
|
name: "invalid json",
|
||||||
body: []byte(`{invalid}`),
|
body: []byte(`{invalid}`),
|
||||||
clientProtocol: "openai",
|
clientProtocol: "openai",
|
||||||
nativePath: "/chat/completions",
|
nativePath: "/v1/chat/completions",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "not chat endpoint",
|
name: "not chat endpoint",
|
||||||
body: []byte(`{"model": "gpt-4", "stream": true}`),
|
body: []byte(`{"model": "gpt-4", "stream": true}`),
|
||||||
clientProtocol: "openai",
|
clientProtocol: "openai",
|
||||||
nativePath: "/models",
|
nativePath: "/v1/models",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -871,8 +871,8 @@ func TestProxyHandler_HandleProxy_Models_LocalAggregation(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -902,8 +902,8 @@ func TestProxyHandler_HandleProxy_ModelInfo_LocalQuery(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/openai/gpt-4"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models/openai/gpt-4"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models/openai/gpt-4", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models/openai/gpt-4", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -936,8 +936,8 @@ func TestProxyHandler_HandleProxy_Models_EmptySuffix_ForwardPassthrough(t *testi
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/models/"}}
|
||||||
c.Request = httptest.NewRequest("GET", "/openai/models/", nil)
|
c.Request = httptest.NewRequest("GET", "/openai/v1/models/", nil)
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -974,8 +974,8 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_UnifiedID(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -1012,8 +1012,8 @@ func TestProxyHandler_HandleProxy_CrossProtocol_NonStream_UnifiedID(t *testing.T
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -1061,8 +1061,8 @@ data: {"type":"message_stop"}
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -1099,8 +1099,8 @@ func TestProxyHandler_HandleProxy_SmartPassthrough_Fidelity(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
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"}`)))
|
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"}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 200, w.Code)
|
assert.Equal(t, 200, w.Code)
|
||||||
@@ -1130,8 +1130,8 @@ func TestProxyHandler_HandleProxy_UnifiedID_ModelNotFound(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
assert.Equal(t, 404, w.Code)
|
assert.Equal(t, 404, w.Code)
|
||||||
@@ -1154,10 +1154,10 @@ func TestProxyHandler_HandleProxy_OpenAIAndAnthropicNativePaths(t *testing.T) {
|
|||||||
responseModel string
|
responseModel string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "openai path has no v1 after gateway prefix",
|
name: "openai path keeps v1 after gateway prefix",
|
||||||
protocol: "openai",
|
protocol: "openai",
|
||||||
path: "/chat/completions",
|
path: "/v1/chat/completions",
|
||||||
requestPath: "/openai/chat/completions",
|
requestPath: "/openai/v1/chat/completions",
|
||||||
baseURL: "https://api.test.com/v1",
|
baseURL: "https://api.test.com/v1",
|
||||||
expectedURL: "https://api.test.com/v1/chat/completions",
|
expectedURL: "https://api.test.com/v1/chat/completions",
|
||||||
body: `{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`,
|
body: `{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`,
|
||||||
@@ -1240,8 +1240,8 @@ func TestProxyHandler_UpstreamNon2xx_Passthrough(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||||
@@ -1272,8 +1272,8 @@ func TestProxyHandler_StreamUpstreamNon2xx_Passthrough(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||||
@@ -1347,8 +1347,8 @@ func TestProxyHandler_InvalidJSON_UsesGatewayError(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
@@ -1373,8 +1373,8 @@ func TestProxyHandler_CrossProtocolMultimodal_Unsupported(t *testing.T) {
|
|||||||
body := []byte(`{"model":"anthropic_p/claude","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`)
|
body := []byte(`{"model":"anthropic_p/claude","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
@@ -1409,8 +1409,8 @@ func TestProxyHandler_SameProtocolMultimodal_SmartPassthrough(t *testing.T) {
|
|||||||
body := []byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`)
|
body := []byte(`{"model":"p1/gpt-4","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc"}}]}]}`)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
@@ -1444,8 +1444,8 @@ func TestProxyHandler_RawStreamPassthrough_PreservesSSEFrames(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/v1/chat/completions"}}
|
||||||
c.Request = httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||||
|
|
||||||
h.HandleProxy(c)
|
h.HandleProxy(c)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) {
|
|||||||
body, _ := json.Marshal(openaiReq)
|
body, _ := json.Marshal(openaiReq)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
|
|||||||
body, _ := json.Marshal(reqBody)
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -382,7 +382,7 @@ func TestConversion_OpenAIToAnthropic_Stream(t *testing.T) {
|
|||||||
body, _ := json.Marshal(openaiReq)
|
body, _ := json.Marshal(openaiReq)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ func TestConversion_ErrorResponse_Format(t *testing.T) {
|
|||||||
|
|
||||||
// OpenAI 协议格式
|
// OpenAI 协议格式
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
assert.True(t, w.Code >= 400)
|
assert.True(t, w.Code >= 400)
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestE2E_OpenAI_NonStream_MultiTurn(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) {
|
|||||||
"tool_choice": "auto",
|
"tool_choice": "auto",
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ func TestE2E_OpenAI_NonStream_MaxTokens_Length(t *testing.T) {
|
|||||||
"max_tokens": 30,
|
"max_tokens": 30,
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ func TestE2E_OpenAI_NonStream_UsageWithReasoning(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}},
|
"messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ func TestE2E_OpenAI_NonStream_Refusal(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "做坏事"}},
|
"messages": []map[string]any{{"role": "user", "content": "做坏事"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -463,7 +463,7 @@ func TestE2E_OpenAI_Stream_Text(t *testing.T) {
|
|||||||
"stream": true,
|
"stream": true,
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -517,7 +517,7 @@ func TestE2E_OpenAI_Stream_ToolCalls(t *testing.T) {
|
|||||||
"stream": true,
|
"stream": true,
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -556,7 +556,7 @@ func TestE2E_OpenAI_Stream_WithUsage(t *testing.T) {
|
|||||||
"stream": true,
|
"stream": true,
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -980,7 +980,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_RequestFormat(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
|
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1063,7 +1063,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream(t *testing.T) {
|
|||||||
"stream": true,
|
"stream": true,
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1140,7 +1140,7 @@ func TestE2E_OpenAI_ErrorResponse(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1225,7 +1225,7 @@ func TestE2E_OpenAI_NonStream_ParallelToolCalls(t *testing.T) {
|
|||||||
"tool_choice": "auto",
|
"tool_choice": "auto",
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1266,7 +1266,7 @@ func TestE2E_OpenAI_NonStream_StopSequence(t *testing.T) {
|
|||||||
"stop": []string{"5"},
|
"stop": []string{"5"},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1303,7 +1303,7 @@ func TestE2E_OpenAI_NonStream_ContentFilter(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "危险内容"}},
|
"messages": []map[string]any{{"role": "user", "content": "危险内容"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1586,7 +1586,7 @@ func TestE2E_CrossProtocol_OpenAIToAnthropic_NonStream_ToolCalls(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1657,7 +1657,7 @@ func TestE2E_CrossProtocol_StopReasonMapping(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "长文"}},
|
"messages": []map[string]any{{"role": "user", "content": "长文"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1707,7 +1707,7 @@ func TestE2E_OpenAI_NonStream_AssistantWithToolResult(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1759,7 +1759,7 @@ func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) {
|
|||||||
"stream": true,
|
"stream": true,
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1835,7 +1835,7 @@ func TestE2E_OpenAI_Upstream5xx_ErrorPassthrough(t *testing.T) {
|
|||||||
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
||||||
})
|
})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("POST", "/openai/chat/completions", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
@@ -1917,6 +1917,95 @@ func TestE2E_Anthropic_Stream_TruncatedSSE(t *testing.T) {
|
|||||||
assert.Contains(t, respBody, "正常")
|
assert.Contains(t, respBody, "正常")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestE2E_OpenAI_Models_LocalAggregation(t *testing.T) {
|
||||||
|
r, upstream := setupE2ETest(t)
|
||||||
|
e2eCreateProviderAndModel(t, r, "openai_p", "openai", "gpt-4o", upstream.URL)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
data, ok := resp["data"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, data, 1)
|
||||||
|
model := data[0].(map[string]any)
|
||||||
|
assert.Equal(t, "openai_p/gpt-4o", model["id"])
|
||||||
|
assert.Equal(t, "openai_p", model["owned_by"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_OpenAI_Embeddings_SameProtocol(t *testing.T) {
|
||||||
|
r, upstream := setupE2ETest(t)
|
||||||
|
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
assert.Equal(t, "/embeddings", req.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"object": "list",
|
||||||
|
"data": []map[string]any{{
|
||||||
|
"index": 0,
|
||||||
|
"embedding": []float64{0.1, 0.2, 0.3},
|
||||||
|
}},
|
||||||
|
"model": "text-embedding-3-small",
|
||||||
|
"usage": map[string]any{
|
||||||
|
"prompt_tokens": 3,
|
||||||
|
"total_tokens": 3,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
e2eCreateProviderAndModel(t, r, "openai_p", "openai", "text-embedding-3-small", upstream.URL)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": "openai_p/text-embedding-3-small",
|
||||||
|
"input": "hello",
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/openai/v1/embeddings", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
assert.Equal(t, "openai_p/text-embedding-3-small", resp["model"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_OpenAI_Rerank_SameProtocol(t *testing.T) {
|
||||||
|
r, upstream := setupE2ETest(t)
|
||||||
|
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
assert.Equal(t, "/rerank", req.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"index": 1,
|
||||||
|
"relevance_score": 0.98,
|
||||||
|
"document": "beta",
|
||||||
|
}},
|
||||||
|
"model": "rerank-v1",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
e2eCreateProviderAndModel(t, r, "openai_p", "openai", "rerank-v1", upstream.URL)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": "openai_p/rerank-v1",
|
||||||
|
"query": "second",
|
||||||
|
"documents": []string{"alpha", "beta"},
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/openai/v1/rerank", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
|
assert.Equal(t, "openai_p/rerank-v1", resp["model"])
|
||||||
|
results, ok := resp["results"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ = fmt.Sprintf
|
_ = fmt.Sprintf
|
||||||
_ = time.Now
|
_ = time.Now
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ 入站: /{protocol}/{native_path} │ │
|
│ │ 入站: /{protocol}/{native_path} │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ /openai/chat/completions → client=openai, /chat/completions │ │
|
│ │ /openai/v1/chat/completions → client=openai, /v1/chat/completions │ │
|
||||||
│ │ /anthropic/v1/messages → client=anthropic, /v1/messages │ │
|
│ │ /anthropic/v1/messages → client=anthropic, /v1/messages │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ Step 1: 识别 client protocol(URL 前缀 / 配置映射 / 任意方式) │ │
|
│ │ Step 1: 识别 client protocol(URL 前缀 / 配置映射 / 任意方式) │ │
|
||||||
@@ -117,8 +117,8 @@
|
|||||||
```
|
```
|
||||||
入站 URL 调用方剥离前缀后 引擎出站
|
入站 URL 调用方剥离前缀后 引擎出站
|
||||||
──────────────────────────────────────────────────────────────────────────────
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
/openai/chat/completions → /chat/completions → /chat/completions
|
/openai/v1/chat/completions → /v1/chat/completions → /chat/completions
|
||||||
/openai/models → /models → /models
|
/openai/v1/models → /v1/models → /models
|
||||||
/anthropic/v1/messages → /v1/messages → /v1/messages
|
/anthropic/v1/messages → /v1/messages → /v1/messages
|
||||||
/anthropic/v1/models → /v1/models → /v1/models
|
/anthropic/v1/models → /v1/models → /v1/models
|
||||||
```
|
```
|
||||||
@@ -459,7 +459,7 @@ interface ProtocolAdapter {
|
|||||||
|
|
||||||
**`buildHeaders` 的设计**:Adapter 只需从 `provider` 中提取自己协议需要的认证和配置信息,构建自己的 Header 格式。不再需要理解其他协议的 Header。
|
**`buildHeaders` 的设计**:Adapter 只需从 `provider` 中提取自己协议需要的认证和配置信息,构建自己的 Header 格式。不再需要理解其他协议的 Header。
|
||||||
|
|
||||||
**URL 事实来源**:Adapter 的 `detectInterfaceType` 和 `buildUrl` 必须以本地 API reference 为事实来源。OpenAI 参考 `docs/api_reference/openai`(忽略 `responses` 目录),其 nativePath 不包含 `/v1`,例如 `/chat/completions`、`/models`、`/embeddings`。Anthropic 参考 `docs/api_reference/anthropic`,其 nativePath 保留 `/v1`,例如 `/v1/messages`、`/v1/models`。不得根据其他协议是否包含 `/v1` 推断当前协议路径。
|
**URL 事实来源**:Adapter 的 `detectInterfaceType` 和 `buildUrl` 必须以本地 API reference 及网关对外协议契约为事实来源。OpenAI 对外路径为 `/openai/v1/...`,剥离协议前缀后的 nativePath 保留 `/v1`,例如 `/v1/chat/completions`、`/v1/models`、`/v1/embeddings`;但 OpenAI 供应商 `base_url` 配置到版本路径一级,`buildUrl` 输出上游 path 时移除 `/v1`,例如 `/chat/completions`、`/models`、`/embeddings`。Anthropic 参考 `docs/api_reference/anthropic`,nativePath 与上游 path 均保留 `/v1`,例如 `/v1/messages`、`/v1/models`。不得根据其他协议是否包含 `/v1` 推断当前协议路径。
|
||||||
|
|
||||||
**智能透传方法的契约**:
|
**智能透传方法的契约**:
|
||||||
- `rewriteRequestModelName` / `rewriteResponseModelName` 必须**幂等**(多次调用结果相同)
|
- `rewriteRequestModelName` / `rewriteResponseModelName` 必须**幂等**(多次调用结果相同)
|
||||||
@@ -868,7 +868,7 @@ engine.registerAdapter(new OpenAIAdapter())
|
|||||||
engine.registerAdapter(new AnthropicAdapter())
|
engine.registerAdapter(new AnthropicAdapter())
|
||||||
|
|
||||||
// 场景1: 跨协议 Chat 转换
|
// 场景1: 跨协议 Chat 转换
|
||||||
// 入站: /openai/chat/completions
|
// 入站: /openai/v1/chat/completions
|
||||||
provider = TargetProvider {
|
provider = TargetProvider {
|
||||||
base_url: "https://api.anthropic.com",
|
base_url: "https://api.anthropic.com",
|
||||||
api_key: "xxx",
|
api_key: "xxx",
|
||||||
@@ -1301,7 +1301,7 @@ Canonical Model 是**活的公共契约**,不是固定不变的。其字段集
|
|||||||
| URL 映射表 | 每种 InterfaceType 的目标 URL 路径(`buildUrl`) |
|
| URL 映射表 | 每种 InterfaceType 的目标 URL 路径(`buildUrl`) |
|
||||||
|
|
||||||
**重要**:`detectInterfaceType` 由各协议 Adapter 实现,因为不同协议有不同的 URL 路径约定。例如:
|
**重要**:`detectInterfaceType` 由各协议 Adapter 实现,因为不同协议有不同的 URL 路径约定。例如:
|
||||||
- OpenAI: `/chat/completions` → CHAT
|
- OpenAI: `/v1/chat/completions` → CHAT
|
||||||
- Anthropic: `/v1/messages` → CHAT
|
- Anthropic: `/v1/messages` → CHAT
|
||||||
|
|
||||||
### D.3 请求头构建
|
### D.3 请求头构建
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
| -------- | ----------------------------------- |
|
| -------- | ----------------------------------- |
|
||||||
| 协议名称 | `"openai"` |
|
| 协议名称 | `"openai"` |
|
||||||
| 协议版本 | 无固定版本头,API 持续演进 |
|
| 协议版本 | 无固定版本头,API 持续演进 |
|
||||||
| Base URL | `https://api.openai.com` |
|
| Base URL | `https://api.openai.com/v1`(供应商配置到版本路径一级) |
|
||||||
| 认证方式 | `Authorization: Bearer <api_key>` |
|
| 认证方式 | `Authorization: Bearer <api_key>` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -47,13 +47,13 @@
|
|||||||
OpenAI.detectInterfaceType(nativePath):
|
OpenAI.detectInterfaceType(nativePath):
|
||||||
if nativePath == "/v1/chat/completions": return CHAT
|
if nativePath == "/v1/chat/completions": return CHAT
|
||||||
if nativePath == "/v1/models": return MODELS
|
if nativePath == "/v1/models": return MODELS
|
||||||
if nativePath matches "^/v1/models/[^/]+$": return MODEL_INFO
|
if nativePath startsWith "/v1/models/" and suffix is not empty: return MODEL_INFO
|
||||||
if nativePath == "/v1/embeddings": return EMBEDDINGS
|
if nativePath == "/v1/embeddings": return EMBEDDINGS
|
||||||
if nativePath == "/v1/rerank": return RERANK
|
if nativePath == "/v1/rerank": return RERANK
|
||||||
return PASSTHROUGH
|
return PASSTHROUGH
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**:`detectInterfaceType` 由 OpenAI Adapter 实现,根据 OpenAI 协议的 URL 路径约定识别接口类型。
|
**说明**:`detectInterfaceType` 由 OpenAI Adapter 实现,根据 OpenAI 协议的 URL 路径约定识别接口类型。网关剥离 `/openai` 协议前缀后,OpenAI Adapter 接收的 nativePath 保留 `/v1`。
|
||||||
|
|
||||||
### 2.3 接口能力矩阵
|
### 2.3 接口能力矩阵
|
||||||
|
|
||||||
@@ -74,14 +74,16 @@ OpenAI.supportsInterface(type):
|
|||||||
```
|
```
|
||||||
OpenAI.buildUrl(nativePath, interfaceType):
|
OpenAI.buildUrl(nativePath, interfaceType):
|
||||||
switch interfaceType:
|
switch interfaceType:
|
||||||
case CHAT: return "/v1/chat/completions"
|
case CHAT: return "/chat/completions"
|
||||||
case MODELS: return "/v1/models"
|
case MODELS: return "/models"
|
||||||
case MODEL_INFO: return "/v1/models/{modelId}"
|
case MODEL_INFO: return "/models/{modelId}"
|
||||||
case EMBEDDINGS: return "/v1/embeddings"
|
case EMBEDDINGS: return "/embeddings"
|
||||||
case RERANK: return "/v1/rerank"
|
case RERANK: return "/rerank"
|
||||||
default: return nativePath
|
default: return nativePath
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**说明**:OpenAI 供应商 `base_url` 配置到版本路径一级,`buildUrl` 输出上游 path 时移除 nativePath 中的 `/v1`,避免拼接出重复版本段。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 请求头构建
|
## 3. 请求头构建
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-04-25
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
当前后端代理入口采用 `/{protocol}/*path`,`ProxyHandler` 将剥离协议前缀后的 path 作为 `nativePath` 传入 adapter。这个模型本身是合理的:OpenAI adapter 应接收 `/chat/completions`,Anthropic adapter 应接收 `/v1/messages`。需要修正的是文档中曾把版本路径抽象为网关统一规则,但实际上版本路径是否存在属于协议原生路径的一部分,应由协议 adapter 识别和映射。
|
|
||||||
|
|
||||||
流式链路当前由 `ProviderClient.SendStream` 按 `\n\n` 拆分 SSE 事件,并把 `data: [DONE]` 转成 Done 信号。这个设计适合跨协议 decoder,但不适合同协议 raw passthrough:透传路径会丢失 SSE frame 结尾空行,Smart Passthrough 也无法对 `data: {...}` 中的 JSON 顶层 model 做可靠改写。
|
|
||||||
|
|
||||||
错误处理目前混合了协议错误、应用错误和上游错误。新的产品决策是:网关层错误使用应用统一 `{error, code}` 格式;只要上游已经返回 HTTP 响应,即使是非 2xx,也保持透明代理语义,直接透传上游 status、headers、body。
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
|
|
||||||
- 明确网关只剥离协议前缀,不对剩余 nativePath 做版本号归一化。
|
|
||||||
- 明确不同协议的 nativePath 形态由协议自身决定:OpenAI 为 `/chat/completions`,Anthropic 为 `/v1/messages`。
|
|
||||||
- 保持现有 `base_url` 约定:OpenAI 配置到版本路径一级,Anthropic 配置到域名级。
|
|
||||||
- 由 adapter 负责识别协议原生 nativePath,并通过 BuildUrl 产出真实上游路径。
|
|
||||||
- 同协议无 model 改写的流式请求保持 raw SSE 字节透传。
|
|
||||||
- 同协议需要 model 改写的流式请求按 SSE frame 解析,只改写 `data` JSON 中的 model 字段,再重建 SSE frame。
|
|
||||||
- 网关层错误统一使用应用错误格式;上游非 2xx 响应透明透传。
|
|
||||||
- 只有 adapter 明确适配的接口才提取 model;未知接口不做通用 model 猜测。
|
|
||||||
- 跨协议遇到多模态占位内容时明确返回不支持错误,避免静默丢内容。
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
|
|
||||||
- 不统一不同协议是否使用 `/v1` 版本路径。
|
|
||||||
- 不调整 OpenAI/Anthropic `base_url` 配置约定。
|
|
||||||
- 不调整统计口径和统计数据模型。
|
|
||||||
- 不实现多模态正式转换。
|
|
||||||
- 不引入新依赖。
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: nativePath 保持协议原生路径
|
|
||||||
|
|
||||||
外部请求 `/{protocol}/{path}` 只剥离协议前缀,剩余 path 原样作为 client protocol 的 nativePath。OpenAI 请求 `/openai/chat/completions` 进入 OpenAI adapter 时为 `/chat/completions`;Anthropic 请求 `/anthropic/v1/messages` 进入 Anthropic adapter 时为 `/v1/messages`。
|
|
||||||
|
|
||||||
替代方案是由网关统一添加或移除 `/v1`。该方案会把版本路径从协议知识错误提升为网关通用规则,导致 Anthropic 和 OpenAI 的 base_url 约定被混淆。
|
|
||||||
|
|
||||||
### Decision 2: 对外路径由协议 adapter 的 nativePath 决定
|
|
||||||
|
|
||||||
网关不提供跨协议统一路径规范。OpenAI 对外路径不带 `/v1`,因为 OpenAI provider 的 `base_url` 已配置到版本路径一级;Anthropic 对外路径保留 `/v1`,因为 Anthropic 协议原生路径包含版本段且 `base_url` 配置到域名级。
|
|
||||||
|
|
||||||
替代方案是把所有协议对外路径都改成不带 `/v1` 或都带 `/v1`。该方案不符合各协议原生路径,也会让 adapter 的 `DetectInterfaceType` 失去协议边界。
|
|
||||||
|
|
||||||
### Decision 3: 上游路径由 adapter.BuildUrl 产生
|
|
||||||
|
|
||||||
无论同协议透传、Smart Passthrough 还是跨协议转换,出站 URL 都使用 `provider.BaseURL + providerAdapter.BuildUrl(nativePath, interfaceType)`。OpenAI adapter 对 `/chat/completions` 返回 `/chat/completions`,配合 `base_url=http://xxx.com/v1` 得到 `http://xxx.com/v1/chat/completions`。Anthropic adapter 对 `/v1/messages` 返回 `/v1/messages`,配合 `base_url=http://xxx.com` 得到 `http://xxx.com/v1/messages`。
|
|
||||||
|
|
||||||
替代方案是在 engine 中直接拼接 `provider.BaseURL + nativePath`。该方案当前对部分协议可工作,但绕过了 adapter 的 URL 映射职责,不利于后续协议扩展和特殊路径映射。
|
|
||||||
|
|
||||||
### Decision 4: 同协议流式分 raw passthrough 和 smart passthrough 两条路径
|
|
||||||
|
|
||||||
当 clientProtocol 等于 providerProtocol 且不需要响应 model 改写时,handler/provider client 应直接将上游响应 body 的 SSE 字节写回客户端,不进入 `StreamConverter` 事件拆分链路。
|
|
||||||
|
|
||||||
当需要响应 model 改写时,保留 SSE frame 边界,解析每个 SSE frame 的 `data` 行:`[DONE]` 原样输出;JSON payload 调用 adapter 的响应 model 改写逻辑后重新写为 SSE frame;解析失败则按宽容策略输出原 frame。
|
|
||||||
|
|
||||||
替代方案是继续让 `ProviderClient.SendStream` 只暴露去掉 `\n\n` 的 rawEvent。该方案无法满足 raw passthrough 的字节语义,也无法可靠输出 `[DONE]`。
|
|
||||||
|
|
||||||
### Decision 5: 网关层错误与上游错误分离
|
|
||||||
|
|
||||||
网关层错误包括路由失败、请求 JSON 错误、转换失败、上游连接失败、跨协议暂不支持能力等,统一返回 `{error, code}`。上游错误指已经收到上游 HTTP 响应的情况,非 2xx 响应不进入 conversion,直接透传 status、过滤后的 headers 和 body。
|
|
||||||
|
|
||||||
替代方案是把所有代理错误都编码为客户端协议错误格式。该方案会隐藏上游原始错误,也与管理接口统一错误格式不一致。
|
|
||||||
|
|
||||||
### Decision 6: adapter 明确声明可提取 model 的接口边界
|
|
||||||
|
|
||||||
Chat、Embeddings、Rerank 等已适配接口由 adapter 的 `ExtractModelName` 明确解析 model。未知接口即使 body 中存在顶层 `model`,也不做假设性提取,按无 model 透传处理。
|
|
||||||
|
|
||||||
替代方案是在 handler 中统一尝试解析顶层 model。该方案会误判未来协议特有接口的模型字段语义,破坏 adapter 对协议知识的封装。
|
|
||||||
|
|
||||||
### Decision 7: 多模态占位保留但跨协议拒绝
|
|
||||||
|
|
||||||
Canonical 中现有 image/audio/video/file 占位保留,后续多模态实现可继续扩展。同协议 Smart Passthrough 保留请求 JSON 语义,不检查多模态。跨协议完整转换检测到 image/audio/video/file 时返回 `UNSUPPORTED_MULTIMODAL` 网关错误。
|
|
||||||
|
|
||||||
替代方案是继续编码空占位或静默丢弃。该方案会制造数据丢失,调用方难以诊断。
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- [Risk] 对外路径由协议决定,调用方需要分别记住 OpenAI 和 Anthropic 的路径形态。→ Mitigation: README、backend README 和设计文档明确列出每个协议的路径和 base_url 约定。
|
|
||||||
- [Risk] raw stream passthrough 可能需要调整 ProviderClient 接口,影响现有测试。→ Mitigation: 将非流式、跨协议流式、同协议 raw 流式分别建模,并补充 E2E 测试。
|
|
||||||
- [Risk] 上游错误透传可能让 OpenAI 客户端看到 Anthropic 错误体。→ Mitigation: 文档明确透明代理边界;只有网关自身错误保证统一格式。
|
|
||||||
- [Risk] SSE frame 级 model 改写需要处理 CRLF、多 data 行和 `[DONE]`。→ Mitigation: 实现轻量 SSE frame parser,覆盖 LF/CRLF、多行 data、解析失败回退原 frame 的测试。
|
|
||||||
- [Risk] 跨协议多模态拒绝会短期限制能力。→ Mitigation: 保留 Canonical 占位和同协议透传,后续多模态 change 可在此基础上扩展。
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
1. 更新 OpenSpec 和设计文档,明确协议原生路径、base_url、错误和流式边界。
|
|
||||||
2. 校正 adapter 路径识别和 `BuildUrl` 映射测试,确保 OpenAI 接收 `/chat/completions`、Anthropic 接收 `/v1/messages`。
|
|
||||||
3. 修改 `ConversionEngine` 同协议 URL 构建,统一使用 `BuildUrl`,避免核心逻辑绕过 adapter。
|
|
||||||
4. 调整 ProviderClient/ProxyHandler 流式链路,支持 raw passthrough、SSE frame 级 Smart Passthrough 和非 2xx 透传。
|
|
||||||
5. 调整网关层错误输出,非 2xx 上游响应绕过 conversion。
|
|
||||||
6. 补齐单测、集成测试和 E2E 测试。
|
|
||||||
7. 更新 README、backend README 和 `docs/conversion_design.md`。
|
|
||||||
|
|
||||||
由于应用尚未上线,不提供额外路径别名兼容和数据迁移。回滚策略是恢复旧流式 provider client 和错误处理行为。
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
无。
|
|
||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
当前 conversion/proxy 层的错误处理和流式透传行为与最新产品决策不一致;路径文档也需要明确职责边界:网关只剥离 `/{protocol}` 前缀,不对剩余路径做版本号归一化,是否包含 `/v1` 由具体协议 adapter 处理。
|
|
||||||
|
|
||||||
这次调整在应用上线前完成,可以避免错误的路径抽象固化,并为后续新增接口和多模态转换打好边界。
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- 明确网关路径职责:只剥离协议前缀 `/{protocol}`,剩余 path 原样作为客户端协议 nativePath 交给 adapter。
|
|
||||||
- 明确不同协议的对外路径由协议自身决定,不做跨协议统一:OpenAI 使用 `/openai/chat/completions`、`/openai/models`、`/openai/embeddings`、`/openai/rerank`;Anthropic 使用 `/anthropic/v1/messages`、`/anthropic/v1/models`、`/anthropic/v1/models/{provider_id}/{model_name}`。
|
|
||||||
- 明确 `base_url` 约定不变:OpenAI 配置到版本路径一级(如 `http://xxx.com/v1`),Anthropic 配置到域名级(如 `http://xxx.com`)。
|
|
||||||
- adapter 负责识别剥离协议前缀后的协议原生 nativePath,并通过 BuildUrl 产出真实上游路径。
|
|
||||||
- 同协议流式无 model 改写时直接透传上游 SSE 字节;需要 model 改写时按 SSE frame 解析和重建,只改写 data JSON 内的 model 字段。
|
|
||||||
- 网关层错误使用应用统一错误格式 `{error, code}`;上游已经返回 HTTP 响应时,非 2xx 状态码、headers、body 直接透传,不进入协议转换。
|
|
||||||
- 只有 adapter 明确适配的接口才提取 model 并参与统一模型 ID 路由;未知接口不做通用 model 猜测,按无 model 透传处理。
|
|
||||||
- 多模态占位保留;本次不实现多模态转换,跨协议检测到 image/audio/video/file 时返回网关层不支持错误。
|
|
||||||
- 本次不调整统计口径。
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
|
|
||||||
- 无
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
|
|
||||||
- `unified-proxy-handler`: 修改 nativePath 语义、同协议流式透传、上游错误透传和未知接口路由规则。
|
|
||||||
- `openai-protocol-proxy`: 明确 OpenAI 对外路径和 adapter 接收的 nativePath 形态,并约束流式完成标记和上游错误透传行为。
|
|
||||||
- `anthropic-protocol-proxy`: 明确 Anthropic 对外路径保持 `/v1` 层级、adapter 接收 `/v1/...` nativePath、上游 URL 由 `base_url + nativePath`/BuildUrl 组合得到。
|
|
||||||
- `error-responses`: 增加网关层统一错误码设计,并明确代理接口中上游错误透传、不做协议错误包装。
|
|
||||||
- `conversion-engine`: 约束同协议 Smart Passthrough 的 URL 构建、SSE frame 级 model 改写、adapter 模型提取边界和跨协议多模态暂不支持行为。
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- 后端代理入口:`backend/internal/handler/proxy_handler.go`
|
|
||||||
- 供应商客户端流式接口:`backend/internal/provider/client.go`
|
|
||||||
- 转换引擎和流式转换器:`backend/internal/conversion/engine.go`、`backend/internal/conversion/stream.go`
|
|
||||||
- OpenAI/Anthropic adapter:`backend/internal/conversion/openai/*`、`backend/internal/conversion/anthropic/*`
|
|
||||||
- 错误处理公共逻辑:`backend/pkg/errors`、handler 错误响应逻辑
|
|
||||||
- 测试:conversion 单测、adapter 单测、代理集成测试、E2E conversion 测试
|
|
||||||
- 文档:`README.md`、`backend/README.md`、`docs/conversion_design.md`
|
|
||||||
- 不引入新依赖,不调整数据库 schema,不调整统计模块
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 支持 Anthropic Messages API 端点
|
|
||||||
|
|
||||||
网关 SHALL 提供 Anthropic Messages API 端点供外部应用调用。
|
|
||||||
|
|
||||||
#### Scenario: 成功的非流式请求
|
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式)
|
|
||||||
- **THEN** 网关 SHALL 剥离 `/anthropic` 前缀并将 `/v1/messages` 作为 Anthropic nativePath
|
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式
|
|
||||||
- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式
|
|
||||||
- **THEN** 若上游返回 2xx,网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
|
|
||||||
|
|
||||||
#### Scenario: 成功的流式请求
|
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true`
|
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
|
|
||||||
- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式,或在同协议 raw passthrough 下透传 Anthropic SSE
|
|
||||||
- **THEN** 网关 SHALL 使用 `event: <type>\ndata: <json>\n\n` 格式流式返回给应用
|
|
||||||
|
|
||||||
#### Scenario: 同协议透传(Anthropic → Anthropic Provider)
|
|
||||||
|
|
||||||
- **WHEN** 客户端使用 Anthropic 协议且目标供应商也是 Anthropic 协议
|
|
||||||
- **THEN** 网关 SHALL 跳过 Canonical 转换
|
|
||||||
- **THEN** 网关 SHALL 使用 Anthropic adapter 重建上游 URL 和认证 Header
|
|
||||||
- **THEN** 若请求使用统一模型 ID,网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
|
|
||||||
|
|
||||||
### Requirement: 双向协议转换
|
|
||||||
|
|
||||||
网关 SHALL 支持 Anthropic 协议与任意已注册协议间的双向转换。
|
|
||||||
|
|
||||||
#### Scenario: Anthropic 客户端 → OpenAI 供应商
|
|
||||||
|
|
||||||
- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议
|
|
||||||
- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest
|
|
||||||
- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest
|
|
||||||
- **THEN** 若上游返回 2xx,SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
|
|
||||||
- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse
|
|
||||||
|
|
||||||
#### Scenario: OpenAI 客户端 → Anthropic 供应商
|
|
||||||
|
|
||||||
- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议
|
|
||||||
- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest
|
|
||||||
- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest
|
|
||||||
- **THEN** 若上游返回 2xx,SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
|
|
||||||
- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse
|
|
||||||
|
|
||||||
#### Scenario: 上游错误透传
|
|
||||||
|
|
||||||
- **WHEN** Anthropic 代理请求收到上游非 2xx HTTP 响应
|
|
||||||
- **THEN** 网关 SHALL 直接透传上游 status code、过滤后的 headers 和 body
|
|
||||||
- **THEN** 网关 SHALL NOT 将上游错误转换为 Anthropic 错误格式
|
|
||||||
|
|
||||||
### Requirement: Anthropic 端点保持 v1 层级
|
|
||||||
|
|
||||||
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以匹配 Anthropic 原生路径约定。
|
|
||||||
|
|
||||||
#### Scenario: Claude Code 调用 Anthropic 端点
|
|
||||||
|
|
||||||
- **WHEN** Claude Code 发送请求到 `/anthropic/v1/messages`
|
|
||||||
- **THEN** 网关 SHALL 正确处理请求
|
|
||||||
- **THEN** nativePath SHALL 为 `/v1/messages`
|
|
||||||
- **THEN** 最终上游 URL SHALL 为 `base_url + /v1/messages`
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Anthropic 上游路径映射
|
|
||||||
|
|
||||||
Anthropic adapter SHALL 将剥离协议前缀后的 Anthropic nativePath 映射为 Anthropic 上游路径。
|
|
||||||
|
|
||||||
#### Scenario: Messages 上游路径
|
|
||||||
|
|
||||||
- **WHEN** nativePath 为 `/v1/messages`
|
|
||||||
- **THEN** Anthropic adapter BuildUrl SHALL 返回 `/v1/messages`
|
|
||||||
|
|
||||||
#### Scenario: Models 上游路径
|
|
||||||
|
|
||||||
- **WHEN** nativePath 为 `/v1/models`
|
|
||||||
- **THEN** Anthropic adapter BuildUrl SHALL 返回 `/v1/models`
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: 同协议请求 URL 使用 Adapter 映射
|
|
||||||
|
|
||||||
ConversionEngine SHALL 在同协议透传和 Smart Passthrough 场景下使用 providerAdapter.BuildUrl 构建上游 URL 路径。
|
|
||||||
|
|
||||||
#### Scenario: 同协议 Chat URL 映射
|
|
||||||
|
|
||||||
- **WHEN** clientProtocol == providerProtocol 且 interfaceType 为 CHAT
|
|
||||||
- **THEN** ConversionEngine SHALL 调用 providerAdapter.BuildUrl(nativePath, interfaceType)
|
|
||||||
- **THEN** 上游 URL SHALL 为 provider.BaseURL 与 BuildUrl 返回路径的组合
|
|
||||||
- **THEN** ConversionEngine SHALL NOT 直接将 provider.BaseURL 与 nativePath 拼接为上游 URL
|
|
||||||
|
|
||||||
#### Scenario: 未知接口 URL 映射
|
|
||||||
|
|
||||||
- **WHEN** interfaceType 为 PASSTHROUGH
|
|
||||||
- **THEN** providerAdapter.BuildUrl SHALL 返回适合目标协议的路径或原 nativePath
|
|
||||||
- **THEN** ConversionEngine SHALL 使用该路径构建上游 URL
|
|
||||||
|
|
||||||
### Requirement: SSE Frame 级 Smart Passthrough
|
|
||||||
|
|
||||||
系统 SHALL 支持同协议流式 Smart Passthrough 对 SSE frame 中的 model 字段进行最小化改写。
|
|
||||||
|
|
||||||
#### Scenario: 改写 SSE data JSON
|
|
||||||
|
|
||||||
- **WHEN** 同协议流式响应需要将上游模型名改写为统一模型 ID
|
|
||||||
- **THEN** 系统 SHALL 按 SSE frame 解析上游字节流
|
|
||||||
- **THEN** 对包含 JSON payload 的 `data` 行 SHALL 调用 adapter.RewriteResponseModelName 改写 model 字段
|
|
||||||
- **THEN** SHALL 重建合法 SSE frame 输出
|
|
||||||
|
|
||||||
#### Scenario: 保留 DONE 事件
|
|
||||||
|
|
||||||
- **WHEN** SSE frame 的 data payload 为 `[DONE]`
|
|
||||||
- **THEN** 系统 SHALL 原样输出 `[DONE]`
|
|
||||||
- **THEN** SHALL NOT 尝试按 JSON 解析
|
|
||||||
|
|
||||||
#### Scenario: 改写失败宽容降级
|
|
||||||
|
|
||||||
- **WHEN** SSE frame 解析或 model 改写失败
|
|
||||||
- **THEN** 系统 SHALL 记录 warn 日志
|
|
||||||
- **THEN** SHALL 输出原始 SSE frame
|
|
||||||
- **THEN** SHALL 继续处理后续 frame
|
|
||||||
|
|
||||||
### Requirement: Adapter 模型提取边界
|
|
||||||
|
|
||||||
ProtocolAdapter SHALL 只对明确适配的接口提供 model 提取和 model 改写能力。
|
|
||||||
|
|
||||||
#### Scenario: 已适配接口提取 model
|
|
||||||
|
|
||||||
- **WHEN** ifaceType 为 adapter 明确支持提取 model 的接口
|
|
||||||
- **THEN** ExtractModelName SHALL 按该协议和接口的请求格式提取 model 字段
|
|
||||||
|
|
||||||
#### Scenario: 未适配接口不提取 model
|
|
||||||
|
|
||||||
- **WHEN** ifaceType 为 PASSTHROUGH 或 adapter 未明确支持提取 model 的接口
|
|
||||||
- **THEN** ExtractModelName SHALL 返回错误或空结果
|
|
||||||
- **THEN** 调用方 SHALL 按无 model 请求处理
|
|
||||||
|
|
||||||
### Requirement: 跨协议多模态暂不支持
|
|
||||||
|
|
||||||
ConversionEngine SHALL 在跨协议完整转换中对当前暂不支持的多模态内容返回明确错误。
|
|
||||||
|
|
||||||
#### Scenario: 跨协议请求包含多模态内容块
|
|
||||||
|
|
||||||
- **WHEN** clientProtocol != providerProtocol 且 CanonicalRequest 中包含 image、audio、video 或 file 内容块
|
|
||||||
- **THEN** ConversionEngine SHALL 中断转换
|
|
||||||
- **THEN** SHALL 返回网关层 `UNSUPPORTED_MULTIMODAL` 错误
|
|
||||||
- **THEN** SHALL NOT 静默丢弃多模态内容
|
|
||||||
|
|
||||||
#### Scenario: 同协议多模态请求
|
|
||||||
|
|
||||||
- **WHEN** clientProtocol == providerProtocol 且请求通过 Smart Passthrough 或 raw passthrough 处理
|
|
||||||
- **THEN** 系统 SHALL 保留原始请求体中未改写字段
|
|
||||||
- **THEN** SHALL NOT 因多模态字段存在而执行跨协议多模态校验
|
|
||||||
|
|
||||||
### Requirement: 上游非 2xx 响应不进入转换
|
|
||||||
|
|
||||||
ConversionEngine SHALL 只转换调用方传入的成功响应,ProxyHandler SHALL 在调用转换前过滤上游非 2xx 响应。
|
|
||||||
|
|
||||||
#### Scenario: 非 2xx 响应绕过响应转换
|
|
||||||
|
|
||||||
- **WHEN** 上游响应状态码不是 2xx
|
|
||||||
- **THEN** ProxyHandler SHALL NOT 调用 ConversionEngine.ConvertHttpResponse
|
|
||||||
- **THEN** ProxyHandler SHALL 直接透传该响应
|
|
||||||
|
|
||||||
#### Scenario: 流式非 2xx 响应绕过流式转换
|
|
||||||
|
|
||||||
- **WHEN** 流式请求收到上游非 2xx 响应
|
|
||||||
- **THEN** ProxyHandler SHALL NOT 调用 ConversionEngine.CreateStreamConverter
|
|
||||||
- **THEN** ProxyHandler SHALL 直接透传该响应
|
|
||||||
|
|
||||||
### Requirement: 协议路径来源
|
|
||||||
|
|
||||||
ProtocolAdapter SHALL 以对应协议的本地 API reference 文档作为 URL 识别和 URL 映射的事实来源。
|
|
||||||
|
|
||||||
#### Scenario: OpenAI 路径来源
|
|
||||||
|
|
||||||
- **WHEN** 实现或测试 OpenAI adapter 的 DetectInterfaceType 或 BuildUrl
|
|
||||||
- **THEN** SHALL 参考 `docs/api_reference/openai` 中的接口路径
|
|
||||||
- **THEN** SHALL 忽略 `docs/api_reference/openai/responses` 目录
|
|
||||||
- **THEN** SHALL NOT 因其他协议包含 `/v1` 而给 OpenAI nativePath 添加 `/v1`
|
|
||||||
|
|
||||||
#### Scenario: Anthropic 路径来源
|
|
||||||
|
|
||||||
- **WHEN** 实现或测试 Anthropic adapter 的 DetectInterfaceType 或 BuildUrl
|
|
||||||
- **THEN** SHALL 参考 `docs/api_reference/anthropic` 中的接口路径
|
|
||||||
- **THEN** SHALL 保留文档中接口路径自带的 `/v1` 前缀
|
|
||||||
- **THEN** SHALL NOT 因 OpenAI nativePath 不含 `/v1` 而移除 Anthropic nativePath 中的 `/v1`
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: 网关层代理错误使用应用统一格式
|
|
||||||
|
|
||||||
系统 SHALL 对代理接口中由网关自身产生的错误使用应用统一错误响应格式。
|
|
||||||
|
|
||||||
#### Scenario: 标准网关错误格式
|
|
||||||
|
|
||||||
- **WHEN** 代理接口返回网关层错误
|
|
||||||
- **THEN** SHALL 使用以下 JSON 格式:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "错误描述",
|
|
||||||
"code": "ERROR_CODE"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
|
||||||
- **THEN** `code` 字段 SHALL 包含机器可读的错误码
|
|
||||||
|
|
||||||
#### Scenario: 网关错误码集合
|
|
||||||
|
|
||||||
- **WHEN** 代理接口返回网关层错误
|
|
||||||
- **THEN** code SHALL 使用以下枚举之一:`INVALID_JSON`、`INVALID_REQUEST`、`INVALID_MODEL_ID`、`MODEL_NOT_FOUND`、`PROVIDER_NOT_FOUND`、`UNSUPPORTED_INTERFACE`、`UNSUPPORTED_MULTIMODAL`、`CONVERSION_FAILED`、`UPSTREAM_UNAVAILABLE`
|
|
||||||
|
|
||||||
### Requirement: 代理接口上游错误透传
|
|
||||||
|
|
||||||
系统 SHALL 对代理接口中已经收到的上游 HTTP 错误响应执行透明透传。
|
|
||||||
|
|
||||||
#### Scenario: 非流式上游非 2xx 响应
|
|
||||||
|
|
||||||
- **WHEN** 非流式代理请求收到上游 HTTP 响应且状态码不是 2xx
|
|
||||||
- **THEN** SHALL 透传上游 status code
|
|
||||||
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
|
||||||
- **THEN** SHALL 透传上游 body
|
|
||||||
- **THEN** SHALL NOT 将上游错误包装为应用统一错误
|
|
||||||
- **THEN** SHALL NOT 将上游错误转换为客户端协议错误格式
|
|
||||||
|
|
||||||
#### Scenario: 流式上游非 2xx 响应
|
|
||||||
|
|
||||||
- **WHEN** 流式代理请求收到上游 HTTP 响应且状态码不是 2xx
|
|
||||||
- **THEN** SHALL 透传上游 status code
|
|
||||||
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
|
||||||
- **THEN** SHALL 透传上游 body
|
|
||||||
- **THEN** SHALL NOT 创建 StreamConverter
|
|
||||||
|
|
||||||
### Requirement: 上游不可达错误
|
|
||||||
|
|
||||||
系统 SHALL 在没有收到上游 HTTP 响应时返回网关层错误。
|
|
||||||
|
|
||||||
#### Scenario: 上游连接失败
|
|
||||||
|
|
||||||
- **WHEN** ProviderClient 因 DNS、连接失败、TLS、超时或上下文取消等原因无法获得上游 HTTP 响应
|
|
||||||
- **THEN** SHALL 返回 HTTP 502 或合适的 5xx 状态码
|
|
||||||
- **THEN** SHALL 返回应用统一错误格式
|
|
||||||
- **THEN** code SHALL 为 `UPSTREAM_UNAVAILABLE`
|
|
||||||
|
|
||||||
### Requirement: Hop-by-hop header 过滤
|
|
||||||
|
|
||||||
系统 SHALL 在透传上游错误响应时过滤 hop-by-hop headers。
|
|
||||||
|
|
||||||
#### Scenario: 过滤连接级 header
|
|
||||||
|
|
||||||
- **WHEN** 透传上游错误响应 headers
|
|
||||||
- **THEN** SHALL 过滤 `Connection`、`Transfer-Encoding`、`Keep-Alive`、`Proxy-Authenticate`、`Proxy-Authorization`、`TE`、`Trailer`、`Upgrade`
|
|
||||||
- **THEN** SHALL 保留 `Content-Type` 等普通响应 header
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: JSON 格式错误
|
|
||||||
|
|
||||||
系统 SHALL 对请求体 JSON 格式错误返回明确的错误信息。
|
|
||||||
|
|
||||||
#### Scenario: 请求体 JSON 格式错误
|
|
||||||
|
|
||||||
- **WHEN** 代理请求的请求体不是有效的 JSON 格式,且该接口需要网关解析请求体
|
|
||||||
- **THEN** SHALL 返回 HTTP 400 Bad Request
|
|
||||||
- **THEN** SHALL 返回以下 JSON 格式:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "请求体 JSON 格式错误",
|
|
||||||
"code": "INVALID_JSON"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario: Smart Passthrough 时请求体 JSON 格式错误
|
|
||||||
|
|
||||||
- **WHEN** 同协议 Smart Passthrough 场景下,请求体 JSON 格式不正确
|
|
||||||
- **THEN** SHALL 返回 HTTP 400 Bad Request
|
|
||||||
- **THEN** SHALL 返回以下 JSON 格式:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "请求体 JSON 格式错误",
|
|
||||||
"code": "INVALID_JSON"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 支持 OpenAI Chat Completions API 端点
|
|
||||||
|
|
||||||
网关 SHALL 提供 OpenAI Chat Completions API 端点供外部应用调用。
|
|
||||||
|
|
||||||
#### Scenario: 成功的非流式请求
|
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
|
||||||
- **THEN** 网关 SHALL 剥离 `/openai` 前缀并将 `/chat/completions` 作为 OpenAI nativePath
|
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求
|
|
||||||
- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商
|
|
||||||
- **THEN** 若上游返回 2xx,网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
|
|
||||||
|
|
||||||
#### Scenario: 成功的流式请求
|
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带 `stream: true`
|
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
|
|
||||||
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用
|
|
||||||
- **THEN** 网关 SHALL 在 OpenAI 协议流完成时发送或透传 `data: [DONE]`
|
|
||||||
|
|
||||||
#### Scenario: 同协议透传(OpenAI → OpenAI Provider)
|
|
||||||
|
|
||||||
- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议
|
|
||||||
- **THEN** 网关 SHALL 跳过 Canonical 转换
|
|
||||||
- **THEN** 网关 SHALL 使用 OpenAI adapter 重建上游 URL 和认证 Header
|
|
||||||
- **THEN** 若请求使用统一模型 ID,网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
|
|
||||||
|
|
||||||
### Requirement: 根据模型名称路由请求
|
|
||||||
|
|
||||||
网关 SHALL 根据请求中的 `model` 字段将请求路由到相应的供应商。
|
|
||||||
|
|
||||||
#### Scenario: 有效模型路由
|
|
||||||
|
|
||||||
- **WHEN** 请求包含有效统一模型 ID 格式的 `model` 字段
|
|
||||||
- **AND** 该模型存在且已启用
|
|
||||||
- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商
|
|
||||||
- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol
|
|
||||||
|
|
||||||
#### Scenario: 模型未找到
|
|
||||||
|
|
||||||
- **WHEN** 请求包含有效统一模型 ID 格式的 `model` 字段但模型不存在
|
|
||||||
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
|
|
||||||
|
|
||||||
#### Scenario: 模型已禁用
|
|
||||||
|
|
||||||
- **WHEN** 请求包含已禁用模型的有效统一模型 ID
|
|
||||||
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
|
|
||||||
|
|
||||||
#### Scenario: 原始模型名兼容透传
|
|
||||||
|
|
||||||
- **WHEN** 请求中的 `model` 字段不是有效统一模型 ID 格式
|
|
||||||
- **THEN** 网关 SHALL 按无可路由 model 请求走 forwardPassthrough
|
|
||||||
|
|
||||||
### Requirement: 跨协议请求转换
|
|
||||||
|
|
||||||
网关 SHALL 对非 OpenAI 兼容供应商的请求和响应通过 ConversionEngine 进行转换处理。
|
|
||||||
|
|
||||||
#### Scenario: 跨协议请求转发
|
|
||||||
|
|
||||||
- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议
|
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式
|
|
||||||
- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header
|
|
||||||
|
|
||||||
#### Scenario: 扩展层接口代理
|
|
||||||
|
|
||||||
- **WHEN** 网关收到 `/openai/models` 等扩展层接口请求
|
|
||||||
- **THEN** 网关 SHALL 通过本地聚合或 ConversionEngine 转换扩展层接口响应格式
|
|
||||||
|
|
||||||
#### Scenario: 上游错误透传
|
|
||||||
|
|
||||||
- **WHEN** OpenAI 代理请求收到上游非 2xx HTTP 响应
|
|
||||||
- **THEN** 网关 SHALL 直接透传上游 status code、过滤后的 headers 和 body
|
|
||||||
- **THEN** 网关 SHALL NOT 将上游错误转换为 OpenAI 错误格式
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 实现统一代理 Handler
|
|
||||||
|
|
||||||
系统 SHALL 实现统一的 ProxyHandler,替代现有的 OpenAIHandler 和 AnthropicHandler。
|
|
||||||
|
|
||||||
ProxyHandler SHALL 依赖 ConversionEngine、ProviderClient、RoutingService、StatsService。
|
|
||||||
|
|
||||||
#### Scenario: 从 URL 提取客户端协议
|
|
||||||
|
|
||||||
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
|
|
||||||
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
|
|
||||||
- **THEN** SHALL 剥离协议前缀得到 nativePath
|
|
||||||
- **THEN** nativePath SHALL 保留剥离协议前缀后的协议原生路径,不添加、不移除版本路径段
|
|
||||||
- **THEN** OpenAI 请求 `/openai/chat/completions` 的 nativePath SHALL 为 `/chat/completions`
|
|
||||||
- **THEN** Anthropic 请求 `/anthropic/v1/messages` 的 nativePath SHALL 为 `/v1/messages`
|
|
||||||
|
|
||||||
#### Scenario: 协议前缀必须是已注册协议
|
|
||||||
|
|
||||||
- **WHEN** 收到的 URL 前缀不是已注册的协议名称
|
|
||||||
- **THEN** SHALL 返回 404 错误
|
|
||||||
- **THEN** 错误响应 SHALL 使用应用统一错误格式
|
|
||||||
|
|
||||||
#### Scenario: 接口类型识别
|
|
||||||
|
|
||||||
- **WHEN** 提取 nativePath 后
|
|
||||||
- **THEN** SHALL 通过 ConversionEngine.convertHttpRequest 内部调用 clientAdapter.detectInterfaceType(nativePath) 识别接口类型
|
|
||||||
- **THEN** 未知路径 SHALL 使用透传模式
|
|
||||||
|
|
||||||
### Requirement: 非流式请求处理流程
|
|
||||||
|
|
||||||
ProxyHandler SHALL 按以下流程处理非流式请求。
|
|
||||||
|
|
||||||
#### Scenario: 完整转换流程
|
|
||||||
|
|
||||||
- **WHEN** 收到非流式请求
|
|
||||||
- **THEN** SHALL 通过客户端 adapter 明确支持的接口规则提取 model
|
|
||||||
- **THEN** SHALL 调用 RoutingService.RouteByModelName(providerID, modelName) 获取路由结果
|
|
||||||
- **THEN** SHALL 从路由结果的 Provider.Protocol 获取 providerProtocol
|
|
||||||
- **THEN** SHALL 构建 TargetProvider(base_url, api_key, model_name, adapter_config)
|
|
||||||
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
|
||||||
- **THEN** SHALL 调用 providerClient.Send(ctx, requestSpec)
|
|
||||||
- **THEN** 若上游响应状态码为 2xx,SHALL 调用 engine.convertHttpResponse 转换响应
|
|
||||||
- **THEN** SHALL 将转换后的响应返回给客户端
|
|
||||||
|
|
||||||
#### Scenario: 路由失败处理
|
|
||||||
|
|
||||||
- **WHEN** RoutingService.RouteByModelName 返回错误
|
|
||||||
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
|
|
||||||
- **THEN** SHALL 返回适当的 HTTP 状态码
|
|
||||||
|
|
||||||
#### Scenario: 上游请求失败处理
|
|
||||||
|
|
||||||
- **WHEN** ProviderClient.Send 未收到上游 HTTP 响应并返回错误
|
|
||||||
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
|
|
||||||
- **THEN** SHALL 包装原始错误信息用于日志
|
|
||||||
|
|
||||||
#### Scenario: 上游非 2xx 响应透传
|
|
||||||
|
|
||||||
- **WHEN** ProviderClient.Send 收到上游 HTTP 响应且状态码不是 2xx
|
|
||||||
- **THEN** ProxyHandler SHALL NOT 调用 engine.convertHttpResponse
|
|
||||||
- **THEN** SHALL 透传上游 status code
|
|
||||||
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
|
||||||
- **THEN** SHALL 透传上游 body
|
|
||||||
|
|
||||||
### Requirement: 流式请求处理流程
|
|
||||||
|
|
||||||
ProxyHandler SHALL 按以下流程处理流式请求。
|
|
||||||
|
|
||||||
#### Scenario: 跨协议流式转换流程
|
|
||||||
|
|
||||||
- **WHEN** 请求中 stream=true 且 clientProtocol != providerProtocol
|
|
||||||
- **THEN** SHALL 执行与非流式相同的前置处理(路由、构建 TargetProvider)
|
|
||||||
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
|
||||||
- **THEN** SHALL 调用 providerClient.SendStream(ctx, requestSpec) 获取流
|
|
||||||
- **THEN** 若上游返回 2xx 流式响应,SHALL 调用 engine.createStreamConverter(clientProtocol, providerProtocol, provider)
|
|
||||||
- **THEN** SHALL 设置响应 Header `Content-Type: text/event-stream`
|
|
||||||
- **THEN** SHALL 对每个流 chunk 调用 converter.processChunk 并写入响应
|
|
||||||
- **THEN** SHALL 在流结束时调用 converter.flush() 刷新缓冲
|
|
||||||
|
|
||||||
#### Scenario: 同协议 raw 流式透传
|
|
||||||
|
|
||||||
- **WHEN** clientProtocol == providerProtocol 且响应不需要 model 改写
|
|
||||||
- **THEN** SHALL 直接将上游 SSE 字节流写入响应
|
|
||||||
- **THEN** SHALL 保留 SSE frame 边界和结束标记
|
|
||||||
- **THEN** SHALL NOT 做任何解析或转换
|
|
||||||
|
|
||||||
#### Scenario: 同协议流式 Smart Passthrough
|
|
||||||
|
|
||||||
- **WHEN** clientProtocol == providerProtocol 且响应需要 model 改写
|
|
||||||
- **THEN** SHALL 按 SSE frame 读取上游流
|
|
||||||
- **THEN** SHALL 仅改写 `data` JSON payload 内的 model 字段
|
|
||||||
- **THEN** SHALL 原样保留 `[DONE]` 结束标记
|
|
||||||
- **THEN** SHALL 重建合法 SSE frame 写回客户端
|
|
||||||
- **THEN** SHALL NOT 做 Canonical decode/encode 转换
|
|
||||||
|
|
||||||
#### Scenario: 流式上游非 2xx 响应透传
|
|
||||||
|
|
||||||
- **WHEN** 流式请求收到上游非 2xx HTTP 响应
|
|
||||||
- **THEN** SHALL NOT 创建 StreamConverter
|
|
||||||
- **THEN** SHALL 透传上游 status code、过滤后的 headers 和 body
|
|
||||||
|
|
||||||
#### Scenario: 流式错误处理
|
|
||||||
|
|
||||||
- **WHEN** 流过程中发生网关层读取或写入错误
|
|
||||||
- **THEN** SHALL 记录错误日志
|
|
||||||
- **THEN** SHALL 关闭响应流
|
|
||||||
|
|
||||||
### Requirement: GET 请求透传
|
|
||||||
|
|
||||||
ProxyHandler SHALL 支持无请求体 GET 请求和未知接口透传。
|
|
||||||
|
|
||||||
#### Scenario: Models 接口本地处理
|
|
||||||
|
|
||||||
- **WHEN** 收到客户端协议的模型列表路径请求,例如 `GET /openai/models` 或 `GET /anthropic/v1/models`
|
|
||||||
- **THEN** SHALL 走模型列表本地聚合流程
|
|
||||||
- **THEN** SHALL NOT 请求上游供应商
|
|
||||||
|
|
||||||
#### Scenario: 未知 GET 接口透传
|
|
||||||
|
|
||||||
- **WHEN** 收到未被 adapter 识别为本地处理接口的 GET 请求
|
|
||||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
|
||||||
- **THEN** SHALL 保留原始 query string
|
|
||||||
|
|
||||||
### Requirement: 代理请求路由
|
|
||||||
|
|
||||||
ProxyHandler SHALL 使用统一模型 ID 路由 adapter 明确支持提取 model 的代理请求。
|
|
||||||
|
|
||||||
#### Scenario: 提取统一模型 ID
|
|
||||||
|
|
||||||
- **WHEN** 收到 adapter 明确支持提取 model 的接口请求(如 Chat、Embeddings 或 Rerank)且请求体非空
|
|
||||||
- **THEN** SHALL 调用客户端协议 adapter 的 `ExtractModelName(body, ifaceType)` 提取 model 值
|
|
||||||
- **THEN** SHALL 调用 `ParseUnifiedModelID` 解析得到 providerID 和 modelName
|
|
||||||
- **THEN** SHALL 调用 `RoutingService.RouteByModelName(providerID, modelName)` 路由
|
|
||||||
|
|
||||||
#### Scenario: 未知接口不猜测 model
|
|
||||||
|
|
||||||
- **WHEN** 收到 adapter 未明确支持提取 model 的接口请求
|
|
||||||
- **THEN** ProxyHandler SHALL NOT 尝试通用解析顶层 model 字段
|
|
||||||
- **THEN** SHALL 按无 model 请求走 forwardPassthrough
|
|
||||||
|
|
||||||
#### Scenario: GET 请求或无请求体
|
|
||||||
|
|
||||||
- **WHEN** 收到 GET 请求或请求体为空或请求体中无法提取 model 字段
|
|
||||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
|
||||||
|
|
||||||
#### Scenario: 无效的统一模型 ID
|
|
||||||
|
|
||||||
- **WHEN** adapter 已提取 model 字段但该字段不是有效的统一模型 ID 格式(不含 `/`)
|
|
||||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
|
||||||
|
|
||||||
#### Scenario: 模型不存在
|
|
||||||
|
|
||||||
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的 provider_id + model_name 组合
|
|
||||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
|
||||||
|
|
||||||
#### Scenario: 模型已禁用
|
|
||||||
|
|
||||||
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
|
|
||||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
|
||||||
|
|
||||||
#### Scenario: 供应商已禁用
|
|
||||||
|
|
||||||
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
|
|
||||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
|
||||||
|
|
||||||
### Requirement: 同协议 Smart Passthrough
|
|
||||||
|
|
||||||
当客户端协议与供应商协议相同时,ProxyHandler SHALL 使用 Smart Passthrough 处理 adapter 明确支持的 Chat、Embedding、Rerank 请求。
|
|
||||||
|
|
||||||
#### Scenario: 同协议非流式请求
|
|
||||||
|
|
||||||
- **WHEN** 客户端协议 == 供应商协议,且为非流式请求
|
|
||||||
- **THEN** SHALL 调用 adapter 的 `RewriteRequestModelName(body, modelName, ifaceType)` 将请求体中 model 从统一 ID 改写为上游模型名
|
|
||||||
- **THEN** SHALL 使用 providerAdapter.BuildUrl(nativePath, ifaceType) 构建上游 URL 路径
|
|
||||||
- **THEN** SHALL 使用 providerAdapter.BuildHeaders(provider) 构建 Headers
|
|
||||||
- **THEN** SHALL 发送改写后的请求体到上游
|
|
||||||
- **THEN** 若上游返回 2xx,SHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
|
|
||||||
- **THEN** SHALL NOT 对 body 做 Canonical 全量 decode → encode,保持未改写字段的 JSON 内容和类型不变,但不承诺保留原始字节顺序或空白
|
|
||||||
|
|
||||||
#### Scenario: 同协议流式请求
|
|
||||||
|
|
||||||
- **WHEN** 客户端协议 == 供应商协议,且为流式请求
|
|
||||||
- **THEN** SHALL 对请求体做 `RewriteRequestModelName` 改写 model 字段
|
|
||||||
- **THEN** SHALL 使用 providerAdapter.BuildUrl(nativePath, ifaceType) 构建上游 URL 路径
|
|
||||||
- **THEN** SHALL 按 raw passthrough 或 SSE frame 级 Smart Passthrough 处理响应流
|
|
||||||
- **THEN** SHALL NOT 对 chunk 做 Canonical 全量 decode → encode
|
|
||||||
|
|
||||||
#### Scenario: Smart Passthrough 保真性
|
|
||||||
|
|
||||||
- **WHEN** 客户端发送含未知参数的请求(如 `{"model":"openai/gpt-4","some_new_param":"value"}`)
|
|
||||||
- **THEN** 上游 SHALL 收到 `{"model":"gpt-4","some_new_param":"value"}`
|
|
||||||
- **THEN** `some_new_param` SHALL 保持原始值不变,不丢失、不改变类型
|
|
||||||
|
|
||||||
### Requirement: 模型列表本地聚合
|
|
||||||
|
|
||||||
ProxyHandler SHALL 从数据库聚合返回模型列表,不再透传上游。
|
|
||||||
|
|
||||||
#### Scenario: 客户端协议模型列表路径
|
|
||||||
|
|
||||||
- **WHEN** 收到客户端协议的模型列表路径请求,例如 `GET /openai/models` 或 `GET /anthropic/v1/models`
|
|
||||||
- **THEN** SHALL 从数据库查询所有 enabled 的模型(关联 enabled 的供应商)
|
|
||||||
- **THEN** SHALL 组装 `CanonicalModelList`,每个模型的 ID 字段为统一模型 ID(`provider_id/model_name`),Name 字段为 model_name,OwnedBy 字段为 provider_id
|
|
||||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
|
||||||
- **THEN** SHALL NOT 请求上游供应商
|
|
||||||
|
|
||||||
#### Scenario: 无可用模型
|
|
||||||
|
|
||||||
- **WHEN** 数据库中没有 enabled 的模型
|
|
||||||
- **THEN** SHALL 返回空列表
|
|
||||||
|
|
||||||
### Requirement: 模型详情本地查询
|
|
||||||
|
|
||||||
ProxyHandler SHALL 从数据库查询返回模型详情,不再透传上游。
|
|
||||||
|
|
||||||
#### Scenario: 客户端协议模型详情路径
|
|
||||||
|
|
||||||
- **WHEN** 收到客户端协议的模型详情路径请求,例如 `GET /openai/models/{provider_id}/{model_name}` 或 `GET /anthropic/v1/models/{provider_id}/{model_name}`
|
|
||||||
- **THEN** SHALL 调用 adapter 的 `ExtractUnifiedModelID` 提取统一模型 ID
|
|
||||||
- **THEN** SHALL 解析统一模型 ID 得到 providerID 和 modelName
|
|
||||||
- **THEN** SHALL 从数据库查询对应的模型和供应商
|
|
||||||
- **THEN** SHALL 组装 `CanonicalModelInfo`,ID 字段为统一模型 ID(`provider_id/model_name`),Name 字段为 model_name,OwnedBy 字段为 provider_id
|
|
||||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
|
||||||
- **THEN** SHALL NOT 请求上游供应商
|
|
||||||
|
|
||||||
#### Scenario: 模型详情不存在
|
|
||||||
|
|
||||||
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
|
|
||||||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
## 1. 路径与 Adapter 映射
|
|
||||||
|
|
||||||
- [x] 1.0 对照 docs/api_reference/openai(忽略 responses 目录)和 docs/api_reference/anthropic 确认本次涉及接口的协议原生路径,并将确认结果写入对应 adapter 测试用例
|
|
||||||
- [x] 1.1 确认 OpenAI adapter 识别剥离协议前缀后的 `/chat/completions`、`/models`、`/embeddings`、`/rerank` 路径并补充测试
|
|
||||||
- [x] 1.2 确认 Anthropic adapter 识别剥离协议前缀后的 `/v1/messages`、`/v1/models`、`/v1/models/{id}` 路径并补充测试
|
|
||||||
- [x] 1.3 补充 BuildUrl 测试,确认 OpenAI `base_url=http://xxx.com/v1` 组合为 `/v1/chat/completions`,Anthropic `base_url=http://xxx.com` 组合为 `/v1/messages`
|
|
||||||
- [x] 1.4 修改 ConversionEngine 同协议请求 URL 构建逻辑,统一使用 providerAdapter.BuildUrl(nativePath, interfaceType)
|
|
||||||
- [x] 1.5 更新 ProxyHandler 和路由测试,确认网关只剥离协议前缀,不对 nativePath 添加或移除 `/v1`
|
|
||||||
|
|
||||||
## 2. 错误响应与上游透传
|
|
||||||
|
|
||||||
- [x] 2.1 增加网关层代理错误响应工具,统一输出 `{error, code}` 和对应 HTTP 状态码
|
|
||||||
- [x] 2.2 将路由失败、JSON 解析失败、转换失败、上游不可达等网关错误改为应用统一错误格式
|
|
||||||
- [x] 2.3 在非流式 handler 中对上游非 2xx 响应绕过 ConvertHttpResponse 并透传 status、过滤后的 headers、body
|
|
||||||
- [x] 2.4 实现 hop-by-hop response header 过滤并补充单元测试
|
|
||||||
- [x] 2.5 调整 ProviderClient 或调用方错误返回,使未收到上游 HTTP 响应时可返回 `UPSTREAM_UNAVAILABLE`
|
|
||||||
|
|
||||||
## 3. 流式链路
|
|
||||||
|
|
||||||
- [x] 3.1 调整 ProviderClient 流式能力,使 handler 可区分上游 2xx 流、上游非 2xx 响应和上游不可达错误
|
|
||||||
- [x] 3.2 实现同协议无 model 改写时的 raw SSE passthrough,保留 frame 边界和 `[DONE]`
|
|
||||||
- [x] 3.3 实现 SSE frame 解析与重建工具,覆盖 LF、CRLF、多 data 行和空 frame
|
|
||||||
- [x] 3.4 实现同协议流式 Smart Passthrough,在 SSE data JSON 内改写 model,失败时输出原 frame 并继续
|
|
||||||
- [x] 3.5 确保跨协议流式仍走 provider StreamDecoder 到 client StreamEncoder 的 CanonicalStreamConverter
|
|
||||||
|
|
||||||
## 4. 路由与能力边界
|
|
||||||
|
|
||||||
- [x] 4.1 明确 adapter 的 ExtractModelName 只支持已适配接口,PASSTHROUGH 或未适配接口返回错误或空结果
|
|
||||||
- [x] 4.2 修改 ProxyHandler,未知接口不做通用顶层 model 猜测,按无 model 请求走 forwardPassthrough
|
|
||||||
- [x] 4.3 保留原始模型名兼容透传逻辑,有效统一模型 ID 但路由失败时返回 `MODEL_NOT_FOUND`
|
|
||||||
- [x] 4.4 在跨协议转换中检测 image、audio、video、file 内容块并返回 `UNSUPPORTED_MULTIMODAL`
|
|
||||||
- [x] 4.5 确保同协议 Smart Passthrough 不因多模态字段存在而拒绝请求
|
|
||||||
|
|
||||||
## 5. 测试
|
|
||||||
|
|
||||||
- [x] 5.1 补充 OpenAI `/openai/chat/completions` 和 Anthropic `/anthropic/v1/messages` 的 handler、E2E 测试
|
|
||||||
- [x] 5.2 补充 nativePath 保持协议原生路径的测试,断言 OpenAI adapter 接收 `/chat/completions`、Anthropic adapter 接收 `/v1/messages`
|
|
||||||
- [x] 5.3 补充同协议流式 raw passthrough 测试,断言 SSE frame 边界和 `[DONE]` 保留
|
|
||||||
- [x] 5.4 补充同协议流式 Smart Passthrough 测试,断言响应 model 改写为统一模型 ID
|
|
||||||
- [x] 5.5 补充非流式和流式上游非 2xx 错误透传测试
|
|
||||||
- [x] 5.6 补充网关层错误统一格式测试,覆盖 `INVALID_JSON`、`MODEL_NOT_FOUND`、`CONVERSION_FAILED`、`UPSTREAM_UNAVAILABLE`
|
|
||||||
- [x] 5.7 补充未知接口不提取 model 的透传测试
|
|
||||||
- [x] 5.8 补充跨协议多模态返回 `UNSUPPORTED_MULTIMODAL` 测试
|
|
||||||
|
|
||||||
## 6. 文档与验证
|
|
||||||
|
|
||||||
- [x] 6.1 更新 README.md 的代理接口路径、错误边界和 base_url 约定说明
|
|
||||||
- [x] 6.2 更新 backend/README.md 的代理接口路径、错误边界和流式透传说明
|
|
||||||
- [x] 6.3 更新 docs/conversion_design.md,使路径、三车道流式行为、错误处理和 adapter 模型提取边界与实现一致
|
|
||||||
- [x] 6.4 运行 `go test ./internal/conversion/...` 并确保通过
|
|
||||||
- [x] 6.5 运行 `go test ./tests/integration` 并确保通过
|
|
||||||
@@ -13,22 +13,24 @@
|
|||||||
#### Scenario: 成功的非流式请求
|
#### Scenario: 成功的非流式请求
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式)
|
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式)
|
||||||
|
- **THEN** 网关 SHALL 剥离 `/anthropic` 前缀并将 `/v1/messages` 作为 Anthropic nativePath
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式
|
- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式
|
||||||
- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式
|
- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式
|
||||||
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
|
- **THEN** 若上游返回 2xx,网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
|
||||||
|
|
||||||
#### Scenario: 成功的流式请求
|
#### Scenario: 成功的流式请求
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true`
|
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true`
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
|
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
|
||||||
- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式
|
- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式,或在同协议 raw passthrough 下透传 Anthropic SSE
|
||||||
- **THEN** 网关 SHALL 使用 `event: <type>\ndata: <json>\n\n` 格式流式返回给应用
|
- **THEN** 网关 SHALL 使用 `event: <type>\ndata: <json>\n\n` 格式流式返回给应用
|
||||||
|
|
||||||
#### Scenario: 同协议透传(Anthropic → Anthropic Provider)
|
#### Scenario: 同协议透传(Anthropic → Anthropic Provider)
|
||||||
|
|
||||||
- **WHEN** 客户端使用 Anthropic 协议且目标供应商也是 Anthropic 协议
|
- **WHEN** 客户端使用 Anthropic 协议且目标供应商也是 Anthropic 协议
|
||||||
- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发
|
- **THEN** 网关 SHALL 跳过 Canonical 转换
|
||||||
- **THEN** 请求和响应 Body SHALL 保持原样
|
- **THEN** 网关 SHALL 使用 Anthropic adapter 重建上游 URL 和认证 Header
|
||||||
|
- **THEN** 若请求使用统一模型 ID,网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
|
||||||
|
|
||||||
### Requirement: 双向协议转换
|
### Requirement: 双向协议转换
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议
|
- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议
|
||||||
- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest
|
- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest
|
||||||
- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest
|
- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest
|
||||||
- **THEN** SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
|
- **THEN** 若上游返回 2xx,SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
|
||||||
- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse
|
- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse
|
||||||
|
|
||||||
#### Scenario: OpenAI 客户端 → Anthropic 供应商
|
#### Scenario: OpenAI 客户端 → Anthropic 供应商
|
||||||
@@ -47,12 +49,18 @@
|
|||||||
- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议
|
- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议
|
||||||
- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest
|
- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest
|
||||||
- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest
|
- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest
|
||||||
- **THEN** SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
|
- **THEN** 若上游返回 2xx,SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
|
||||||
- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse
|
- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse
|
||||||
|
|
||||||
|
#### Scenario: 上游错误透传
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 代理请求收到上游非 2xx HTTP 响应
|
||||||
|
- **THEN** 网关 SHALL 直接透传上游 status code、过滤后的 headers 和 body
|
||||||
|
- **THEN** 网关 SHALL NOT 将上游错误转换为 Anthropic 错误格式
|
||||||
|
|
||||||
### Requirement: Anthropic 端点保持 v1 层级
|
### Requirement: Anthropic 端点保持 v1 层级
|
||||||
|
|
||||||
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以兼容 Claude Code。
|
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以匹配 Anthropic 原生路径约定。
|
||||||
|
|
||||||
#### Scenario: Claude Code 调用 Anthropic 端点
|
#### Scenario: Claude Code 调用 Anthropic 端点
|
||||||
|
|
||||||
@@ -60,3 +68,17 @@ Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以兼容 Claude Code
|
|||||||
- **THEN** 网关 SHALL 正确处理请求
|
- **THEN** 网关 SHALL 正确处理请求
|
||||||
- **THEN** nativePath SHALL 为 `/v1/messages`
|
- **THEN** nativePath SHALL 为 `/v1/messages`
|
||||||
- **THEN** 最终上游 URL SHALL 为 `base_url + /v1/messages`
|
- **THEN** 最终上游 URL SHALL 为 `base_url + /v1/messages`
|
||||||
|
|
||||||
|
### Requirement: Anthropic 上游路径映射
|
||||||
|
|
||||||
|
Anthropic adapter SHALL 将剥离协议前缀后的 Anthropic nativePath 映射为 Anthropic 上游路径。
|
||||||
|
|
||||||
|
#### Scenario: Messages 上游路径
|
||||||
|
|
||||||
|
- **WHEN** nativePath 为 `/v1/messages`
|
||||||
|
- **THEN** Anthropic adapter BuildUrl SHALL 返回 `/v1/messages`
|
||||||
|
|
||||||
|
#### Scenario: Models 上游路径
|
||||||
|
|
||||||
|
- **WHEN** nativePath 为 `/v1/models`
|
||||||
|
- **THEN** Anthropic adapter BuildUrl SHALL 返回 `/v1/models`
|
||||||
|
|||||||
@@ -325,3 +325,137 @@ TargetProvider 的 ModelName 字段 SHALL 存储上游供应商的模型名称
|
|||||||
|
|
||||||
- **WHEN** 协议适配器编码请求时
|
- **WHEN** 协议适配器编码请求时
|
||||||
- **THEN** SHALL 使用 `TargetProvider.ModelName` 作为发给上游的 `model` 字段值(值为路由结果中的 model_name)
|
- **THEN** SHALL 使用 `TargetProvider.ModelName` 作为发给上游的 `model` 字段值(值为路由结果中的 model_name)
|
||||||
|
|
||||||
|
### Requirement: 同协议请求 URL 使用 Adapter 映射
|
||||||
|
|
||||||
|
ConversionEngine SHALL 在同协议透传和 Smart Passthrough 场景下使用 providerAdapter.BuildUrl 构建上游 URL 路径。
|
||||||
|
|
||||||
|
#### Scenario: 同协议 Chat URL 映射
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol == providerProtocol 且 interfaceType 为 CHAT
|
||||||
|
- **THEN** ConversionEngine SHALL 调用 providerAdapter.BuildUrl(nativePath, interfaceType)
|
||||||
|
- **THEN** 上游 URL SHALL 为 provider.BaseURL 与 BuildUrl 返回路径的组合
|
||||||
|
- **THEN** ConversionEngine SHALL NOT 直接将 provider.BaseURL 与 nativePath 拼接为上游 URL
|
||||||
|
|
||||||
|
#### Scenario: 未知接口 URL 映射
|
||||||
|
|
||||||
|
- **WHEN** interfaceType 为 PASSTHROUGH
|
||||||
|
- **THEN** providerAdapter.BuildUrl SHALL 返回适合目标协议的路径或原 nativePath
|
||||||
|
- **THEN** ConversionEngine SHALL 使用该路径构建上游 URL
|
||||||
|
|
||||||
|
### Requirement: 上游 URL 构建使用目标协议 Adapter
|
||||||
|
|
||||||
|
ConversionEngine SHALL 始终使用目标供应商协议的 adapter 构建上游 URL 路径,避免客户端协议 nativePath 中的版本段泄露到目标协议上游 URL。
|
||||||
|
|
||||||
|
#### Scenario: OpenAI 客户端到 OpenAI 供应商
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol 为 `openai`,providerProtocol 为 `openai`,nativePath 为 `/v1/chat/completions`
|
||||||
|
- **THEN** ConversionEngine SHALL 调用 OpenAI adapter 的 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||||
|
- **THEN** 上游 path SHALL 为 `/chat/completions`
|
||||||
|
- **THEN** 当 provider.base_url 为 `https://api.openai.com/v1` 时,最终上游 URL SHALL 为 `https://api.openai.com/v1/chat/completions`
|
||||||
|
|
||||||
|
#### Scenario: OpenAI 客户端到 Anthropic 供应商
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol 为 `openai`,providerProtocol 为 `anthropic`,nativePath 为 `/v1/chat/completions`
|
||||||
|
- **THEN** ConversionEngine SHALL 使用 OpenAI adapter 识别接口类型为 `InterfaceTypeChat`
|
||||||
|
- **THEN** ConversionEngine SHALL 调用 Anthropic adapter 的 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||||
|
- **THEN** 上游 path SHALL 为 `/v1/messages`
|
||||||
|
- **THEN** OpenAI nativePath 中的 `/v1/chat/completions` SHALL NOT 被直接拼接到 Anthropic 上游 URL
|
||||||
|
|
||||||
|
#### Scenario: Anthropic 客户端到 OpenAI 供应商
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol 为 `anthropic`,providerProtocol 为 `openai`,nativePath 为 `/v1/messages`
|
||||||
|
- **THEN** ConversionEngine SHALL 使用 Anthropic adapter 识别接口类型为 `InterfaceTypeChat`
|
||||||
|
- **THEN** ConversionEngine SHALL 调用 OpenAI adapter 的 `BuildUrl(nativePath, InterfaceTypeChat)`
|
||||||
|
- **THEN** 上游 path SHALL 为 `/chat/completions`
|
||||||
|
- **THEN** 当 provider.base_url 为 `https://api.openai.com/v1` 时,最终上游 URL SHALL 为 `https://api.openai.com/v1/chat/completions`
|
||||||
|
|
||||||
|
### Requirement: SSE Frame 级 Smart Passthrough
|
||||||
|
|
||||||
|
系统 SHALL 支持同协议流式 Smart Passthrough 对 SSE frame 中的 model 字段进行最小化改写。
|
||||||
|
|
||||||
|
#### Scenario: 改写 SSE data JSON
|
||||||
|
|
||||||
|
- **WHEN** 同协议流式响应需要将上游模型名改写为统一模型 ID
|
||||||
|
- **THEN** 系统 SHALL 按 SSE frame 解析上游字节流
|
||||||
|
- **THEN** 对包含 JSON payload 的 `data` 行 SHALL 调用 adapter.RewriteResponseModelName 改写 model 字段
|
||||||
|
- **THEN** SHALL 重建合法 SSE frame 输出
|
||||||
|
|
||||||
|
#### Scenario: 保留 DONE 事件
|
||||||
|
|
||||||
|
- **WHEN** SSE frame 的 data payload 为 `[DONE]`
|
||||||
|
- **THEN** 系统 SHALL 原样输出 `[DONE]`
|
||||||
|
- **THEN** SHALL NOT 尝试按 JSON 解析
|
||||||
|
|
||||||
|
#### Scenario: 改写失败宽容降级
|
||||||
|
|
||||||
|
- **WHEN** SSE frame 解析或 model 改写失败
|
||||||
|
- **THEN** 系统 SHALL 记录 warn 日志
|
||||||
|
- **THEN** SHALL 输出原始 SSE frame
|
||||||
|
- **THEN** SHALL 继续处理后续 frame
|
||||||
|
|
||||||
|
### Requirement: Adapter 模型提取边界
|
||||||
|
|
||||||
|
ProtocolAdapter SHALL 只对明确适配的接口提供 model 提取和 model 改写能力。
|
||||||
|
|
||||||
|
#### Scenario: 已适配接口提取 model
|
||||||
|
|
||||||
|
- **WHEN** ifaceType 为 adapter 明确支持提取 model 的接口
|
||||||
|
- **THEN** ExtractModelName SHALL 按该协议和接口的请求格式提取 model 字段
|
||||||
|
|
||||||
|
#### Scenario: 未适配接口不提取 model
|
||||||
|
|
||||||
|
- **WHEN** ifaceType 为 PASSTHROUGH 或 adapter 未明确支持提取 model 的接口
|
||||||
|
- **THEN** ExtractModelName SHALL 返回错误或空结果
|
||||||
|
- **THEN** 调用方 SHALL 按无 model 请求处理
|
||||||
|
|
||||||
|
### Requirement: 跨协议多模态暂不支持
|
||||||
|
|
||||||
|
ConversionEngine SHALL 在跨协议完整转换中对当前暂不支持的多模态内容返回明确错误。
|
||||||
|
|
||||||
|
#### Scenario: 跨协议请求包含多模态内容块
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol != providerProtocol 且 CanonicalRequest 中包含 image、audio、video 或 file 内容块
|
||||||
|
- **THEN** ConversionEngine SHALL 中断转换
|
||||||
|
- **THEN** SHALL 返回网关层 `UNSUPPORTED_MULTIMODAL` 错误
|
||||||
|
- **THEN** SHALL NOT 静默丢弃多模态内容
|
||||||
|
|
||||||
|
#### Scenario: 同协议多模态请求
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol == providerProtocol 且请求通过 Smart Passthrough 或 raw passthrough 处理
|
||||||
|
- **THEN** 系统 SHALL 保留原始请求体中未改写字段
|
||||||
|
- **THEN** SHALL NOT 因多模态字段存在而执行跨协议多模态校验
|
||||||
|
|
||||||
|
### Requirement: 上游非 2xx 响应不进入转换
|
||||||
|
|
||||||
|
ConversionEngine SHALL 只转换调用方传入的成功响应,ProxyHandler SHALL 在调用转换前过滤上游非 2xx 响应。
|
||||||
|
|
||||||
|
#### Scenario: 非 2xx 响应绕过响应转换
|
||||||
|
|
||||||
|
- **WHEN** 上游响应状态码不是 2xx
|
||||||
|
- **THEN** ProxyHandler SHALL NOT 调用 ConversionEngine.ConvertHttpResponse
|
||||||
|
- **THEN** ProxyHandler SHALL 直接透传该响应
|
||||||
|
|
||||||
|
#### Scenario: 流式非 2xx 响应绕过流式转换
|
||||||
|
|
||||||
|
- **WHEN** 流式请求收到上游非 2xx 响应
|
||||||
|
- **THEN** ProxyHandler SHALL NOT 调用 ConversionEngine.CreateStreamConverter
|
||||||
|
- **THEN** ProxyHandler SHALL 直接透传该响应
|
||||||
|
|
||||||
|
### Requirement: 协议路径来源
|
||||||
|
|
||||||
|
ProtocolAdapter SHALL 以对应协议的本地 API reference 文档作为 URL 识别和 URL 映射的事实来源。
|
||||||
|
|
||||||
|
#### Scenario: OpenAI 路径来源
|
||||||
|
|
||||||
|
- **WHEN** 实现或测试 OpenAI adapter 的 DetectInterfaceType 或 BuildUrl
|
||||||
|
- **THEN** SHALL 参考 `docs/api_reference/openai` 中的接口路径
|
||||||
|
- **THEN** SHALL 忽略 `docs/api_reference/openai/responses` 目录
|
||||||
|
- **THEN** SHALL NOT 因其他协议包含 `/v1` 而给 OpenAI nativePath 添加 `/v1`
|
||||||
|
|
||||||
|
#### Scenario: Anthropic 路径来源
|
||||||
|
|
||||||
|
- **WHEN** 实现或测试 Anthropic adapter 的 DetectInterfaceType 或 BuildUrl
|
||||||
|
- **THEN** SHALL 参考 `docs/api_reference/anthropic` 中的接口路径
|
||||||
|
- **THEN** SHALL 保留文档中接口路径自带的 `/v1` 前缀
|
||||||
|
- **THEN** SHALL NOT 因 OpenAI nativePath 不含 `/v1` 而移除 Anthropic nativePath 中的 `/v1`
|
||||||
|
|||||||
@@ -73,21 +73,46 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
### Requirement: 静态文件服务
|
### Requirement: 静态文件服务
|
||||||
|
|
||||||
系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。
|
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
|
||||||
|
|
||||||
#### Scenario: API 请求路由
|
#### Scenario: API 请求路由
|
||||||
|
|
||||||
- **WHEN** 请求路径以 `/api/` 或 `/v1/` 开头
|
- **WHEN** 请求路径以 `/api/` 或 `/health` 开头
|
||||||
- **THEN** 请求由现有业务 handler 处理
|
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
|
||||||
|
|
||||||
|
#### Scenario: 协议代理请求路由
|
||||||
|
|
||||||
|
- **WHEN** 请求路径以 `/openai/` 或 `/anthropic/` 开头
|
||||||
|
- **THEN** 请求 SHALL 被视为协议代理请求或返回 API 风格 404
|
||||||
|
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
|
||||||
|
|
||||||
|
#### Scenario: OpenAI 代理路由
|
||||||
|
|
||||||
|
- **WHEN** desktop 模式收到 `/openai/v1/chat/completions` 请求
|
||||||
|
- **THEN** 请求 SHALL 进入 ProxyHandler
|
||||||
|
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `openai`
|
||||||
|
|
||||||
|
#### Scenario: Anthropic 代理路由
|
||||||
|
|
||||||
|
- **WHEN** desktop 模式收到 `/anthropic/v1/messages` 请求
|
||||||
|
- **THEN** 请求 SHALL 进入 ProxyHandler
|
||||||
|
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `anthropic`
|
||||||
|
|
||||||
#### Scenario: 静态资源路由
|
#### Scenario: 静态资源路由
|
||||||
|
|
||||||
- **WHEN** 请求路径为 `/assets/*`
|
- **WHEN** 请求路径为 `/assets/*`
|
||||||
- **THEN** 返回嵌入的前端静态资源文件
|
- **THEN** 返回嵌入的前端静态资源文件
|
||||||
|
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||||
|
|
||||||
|
#### Scenario: Favicon 路由
|
||||||
|
|
||||||
|
- **WHEN** 请求路径为 `/favicon.svg`
|
||||||
|
- **THEN** 返回嵌入的前端 favicon 资源
|
||||||
|
- **THEN** 请求 SHALL NOT 被协议代理路由处理
|
||||||
|
|
||||||
#### Scenario: SPA 路由回退
|
#### Scenario: SPA 路由回退
|
||||||
|
|
||||||
- **WHEN** 请求路径不匹配任何 API 或静态资源路由
|
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
|
||||||
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
|
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
|
||||||
|
|
||||||
### Requirement: 端口冲突检测
|
### Requirement: 端口冲突检测
|
||||||
|
|||||||
@@ -23,6 +23,70 @@
|
|||||||
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
||||||
- **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选)
|
- **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选)
|
||||||
|
|
||||||
|
### Requirement: 网关层代理错误使用应用统一格式
|
||||||
|
|
||||||
|
系统 SHALL 对代理接口中由网关自身产生的错误使用应用统一错误响应格式。
|
||||||
|
|
||||||
|
#### Scenario: 标准网关错误格式
|
||||||
|
|
||||||
|
- **WHEN** 代理接口返回网关层错误
|
||||||
|
- **THEN** SHALL 使用以下 JSON 格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "错误描述",
|
||||||
|
"code": "ERROR_CODE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
|
||||||
|
- **THEN** `code` 字段 SHALL 包含机器可读的错误码
|
||||||
|
|
||||||
|
#### Scenario: 网关错误码集合
|
||||||
|
|
||||||
|
- **WHEN** 代理接口返回网关层错误
|
||||||
|
- **THEN** code SHALL 使用以下枚举之一:`INVALID_JSON`、`INVALID_REQUEST`、`INVALID_MODEL_ID`、`MODEL_NOT_FOUND`、`PROVIDER_NOT_FOUND`、`UNSUPPORTED_INTERFACE`、`UNSUPPORTED_MULTIMODAL`、`CONVERSION_FAILED`、`UPSTREAM_UNAVAILABLE`
|
||||||
|
|
||||||
|
### Requirement: 代理接口上游错误透传
|
||||||
|
|
||||||
|
系统 SHALL 对代理接口中已经收到的上游 HTTP 错误响应执行透明透传。
|
||||||
|
|
||||||
|
#### Scenario: 非流式上游非 2xx 响应
|
||||||
|
|
||||||
|
- **WHEN** 非流式代理请求收到上游 HTTP 响应且状态码不是 2xx
|
||||||
|
- **THEN** SHALL 透传上游 status code
|
||||||
|
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
||||||
|
- **THEN** SHALL 透传上游 body
|
||||||
|
- **THEN** SHALL NOT 将上游错误包装为应用统一错误
|
||||||
|
- **THEN** SHALL NOT 将上游错误转换为客户端协议错误格式
|
||||||
|
|
||||||
|
#### Scenario: 流式上游非 2xx 响应
|
||||||
|
|
||||||
|
- **WHEN** 流式代理请求收到上游 HTTP 响应且状态码不是 2xx
|
||||||
|
- **THEN** SHALL 透传上游 status code
|
||||||
|
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
||||||
|
- **THEN** SHALL 透传上游 body
|
||||||
|
- **THEN** SHALL NOT 创建 StreamConverter
|
||||||
|
|
||||||
|
### Requirement: 上游不可达错误
|
||||||
|
|
||||||
|
系统 SHALL 在没有收到上游 HTTP 响应时返回网关层错误。
|
||||||
|
|
||||||
|
#### Scenario: 上游连接失败
|
||||||
|
|
||||||
|
- **WHEN** ProviderClient 因 DNS、连接失败、TLS、超时或上下文取消等原因无法获得上游 HTTP 响应
|
||||||
|
- **THEN** SHALL 返回 HTTP 502 或合适的 5xx 状态码
|
||||||
|
- **THEN** SHALL 返回应用统一错误格式
|
||||||
|
- **THEN** code SHALL 为 `UPSTREAM_UNAVAILABLE`
|
||||||
|
|
||||||
|
### Requirement: Hop-by-hop header 过滤
|
||||||
|
|
||||||
|
系统 SHALL 在透传上游错误响应时过滤 hop-by-hop headers。
|
||||||
|
|
||||||
|
#### Scenario: 过滤连接级 header
|
||||||
|
|
||||||
|
- **WHEN** 透传上游错误响应 headers
|
||||||
|
- **THEN** SHALL 过滤 `Connection`、`Transfer-Encoding`、`Keep-Alive`、`Proxy-Authenticate`、`Proxy-Authorization`、`TE`、`Trailer`、`Upgrade`
|
||||||
|
- **THEN** SHALL 保留 `Content-Type` 等普通响应 header
|
||||||
|
|
||||||
### Requirement: 前端提取并处理错误码
|
### Requirement: 前端提取并处理错误码
|
||||||
|
|
||||||
前端 SHALL 提取后端结构化错误响应中的错误码并用于错误处理。
|
前端 SHALL 提取后端结构化错误响应中的错误码并用于错误处理。
|
||||||
@@ -212,7 +276,7 @@
|
|||||||
|
|
||||||
#### Scenario: 请求体 JSON 格式错误
|
#### Scenario: 请求体 JSON 格式错误
|
||||||
|
|
||||||
- **WHEN** 代理请求的请求体不是有效的 JSON 格式
|
- **WHEN** 代理请求的请求体不是有效的 JSON 格式,且该接口需要网关解析请求体
|
||||||
- **THEN** SHALL 返回 HTTP 400 Bad Request
|
- **THEN** SHALL 返回 HTTP 400 Bad Request
|
||||||
- **THEN** SHALL 返回以下 JSON 格式:
|
- **THEN** SHALL 返回以下 JSON 格式:
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -2,33 +2,66 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
定义 OpenAI Chat Completions API 端点的协议代理行为,包括请求处理流程、模型路由、同协议透传和跨协议转换。
|
定义 OpenAI Chat Completions API 端点及扩展层端点的协议代理行为,包括请求处理流程、模型路由、同协议透传和跨协议转换。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 支持 OpenAI 扩展层 v1 端点
|
||||||
|
|
||||||
|
网关 SHALL 提供带 `/v1` 层级的 OpenAI 扩展层端点供外部应用调用。
|
||||||
|
|
||||||
|
#### Scenario: Models 列表端点
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 GET 请求到 `/openai/v1/models`
|
||||||
|
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/models` 作为 OpenAI nativePath
|
||||||
|
- **THEN** 网关 SHALL 返回 OpenAI Models 格式响应
|
||||||
|
|
||||||
|
#### Scenario: ModelInfo 详情端点
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 GET 请求到 `/openai/v1/models/{provider_id}/{model_name}`
|
||||||
|
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/models/{provider_id}/{model_name}` 作为 OpenAI nativePath
|
||||||
|
- **THEN** 网关 SHALL 返回 OpenAI ModelInfo 格式响应
|
||||||
|
|
||||||
|
#### Scenario: Embeddings 端点
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 POST 请求到 `/openai/v1/embeddings`
|
||||||
|
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/embeddings` 作为 OpenAI nativePath
|
||||||
|
- **THEN** 网关 SHALL 使用 OpenAI adapter 识别并处理 Embeddings 请求
|
||||||
|
|
||||||
|
#### Scenario: Rerank 端点
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 POST 请求到 `/openai/v1/rerank`
|
||||||
|
- **THEN** 网关 SHALL 将剥离 `/openai` 后的 `/v1/rerank` 作为 OpenAI nativePath
|
||||||
|
- **THEN** 网关 SHALL 使用 OpenAI adapter 识别并处理 Rerank 请求
|
||||||
|
|
||||||
### Requirement: 支持 OpenAI Chat Completions API 端点
|
### Requirement: 支持 OpenAI Chat Completions API 端点
|
||||||
|
|
||||||
网关 SHALL 提供 OpenAI Chat Completions API 端点供外部应用调用。
|
网关 SHALL 提供带 `/v1` 层级的 OpenAI Chat Completions API 端点供外部应用调用。
|
||||||
|
|
||||||
#### Scenario: 成功的非流式请求
|
#### Scenario: 成功的非流式请求
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
||||||
|
- **THEN** 网关 SHALL 剥离 `/openai` 前缀并将 `/v1/chat/completions` 作为 OpenAI nativePath
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求
|
- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求
|
||||||
- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商
|
- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商
|
||||||
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
|
- **THEN** 若上游返回 2xx,网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
|
||||||
|
|
||||||
#### Scenario: 成功的流式请求
|
#### Scenario: 成功的流式请求
|
||||||
|
|
||||||
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带 `stream: true`
|
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带 `stream: true`
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
|
- **THEN** 网关 SHALL 剥离 `/openai` 前缀并将 `/v1/chat/completions` 作为 OpenAI nativePath
|
||||||
|
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
|
||||||
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用
|
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用
|
||||||
- **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]`
|
- **THEN** 网关 SHALL 在 OpenAI 协议流完成时发送或透传 `data: [DONE]`
|
||||||
|
|
||||||
#### Scenario: 同协议透传(OpenAI → OpenAI Provider)
|
#### Scenario: 同协议透传(OpenAI → OpenAI Provider)
|
||||||
|
|
||||||
- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议
|
- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议
|
||||||
- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发
|
- **THEN** 网关 SHALL 跳过 Canonical 转换
|
||||||
- **THEN** 请求和响应 Body SHALL 保持原样
|
- **THEN** 网关 SHALL 使用 OpenAI adapter 重建上游 URL 和认证 Header
|
||||||
|
- **THEN** 若请求使用统一模型 ID,网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
|
||||||
|
- **THEN** OpenAI adapter SHALL 将 `/v1/chat/completions` 映射为上游路径 `/chat/completions`
|
||||||
|
- **THEN** OpenAI 供应商 `base_url` 配置到版本路径一级时,最终上游 URL SHALL 不重复 `/v1`
|
||||||
|
|
||||||
### Requirement: 根据模型名称路由请求
|
### Requirement: 根据模型名称路由请求
|
||||||
|
|
||||||
@@ -36,20 +69,25 @@
|
|||||||
|
|
||||||
#### Scenario: 有效模型路由
|
#### Scenario: 有效模型路由
|
||||||
|
|
||||||
- **WHEN** 请求包含存在于配置模型中的 `model` 字段
|
- **WHEN** 请求包含有效统一模型 ID 格式的 `model` 字段
|
||||||
- **AND** 该模型已启用
|
- **AND** 该模型存在且已启用
|
||||||
- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商
|
- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商
|
||||||
- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol
|
- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol
|
||||||
|
|
||||||
#### Scenario: 模型未找到
|
#### Scenario: 模型未找到
|
||||||
|
|
||||||
- **WHEN** 请求包含不存在于配置模型中的 `model` 字段
|
- **WHEN** 请求包含有效统一模型 ID 格式的 `model` 字段但模型不存在
|
||||||
- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应
|
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
|
||||||
|
|
||||||
#### Scenario: 模型已禁用
|
#### Scenario: 模型已禁用
|
||||||
|
|
||||||
- **WHEN** 请求包含已禁用模型的 `model` 字段
|
- **WHEN** 请求包含已禁用模型的有效统一模型 ID
|
||||||
- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应
|
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
|
||||||
|
|
||||||
|
#### Scenario: 原始模型名兼容透传
|
||||||
|
|
||||||
|
- **WHEN** 请求中的 `model` 字段不是有效统一模型 ID 格式
|
||||||
|
- **THEN** 网关 SHALL 按无可路由 model 请求走 forwardPassthrough
|
||||||
|
|
||||||
### Requirement: 跨协议请求转换
|
### Requirement: 跨协议请求转换
|
||||||
|
|
||||||
@@ -60,8 +98,16 @@
|
|||||||
- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议
|
- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式
|
- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式
|
||||||
- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header
|
- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header
|
||||||
|
- **THEN** 目标协议 Adapter SHALL 基于接口类型构建目标协议路径,不直接复用 OpenAI nativePath 中的 `/v1` 版本段
|
||||||
|
|
||||||
#### Scenario: 扩展层接口代理
|
#### Scenario: 扩展层接口代理
|
||||||
|
|
||||||
- **WHEN** 网关收到 `/openai/v1/models` 等 GET 请求
|
- **WHEN** 网关收到 `/openai/v1/models` 等 GET 请求
|
||||||
- **THEN** 网关 SHALL 通过 ConversionEngine 转换扩展层接口的响应格式
|
- **THEN** 网关 SHALL 将 `/v1/models` 作为 OpenAI nativePath 识别扩展层接口
|
||||||
|
- **THEN** 网关 SHALL 通过本地聚合或 ConversionEngine 转换扩展层接口响应格式
|
||||||
|
|
||||||
|
#### Scenario: 上游错误透传
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 代理请求收到上游非 2xx HTTP 响应
|
||||||
|
- **THEN** 网关 SHALL 直接透传上游 status code、过滤后的 headers 和 body
|
||||||
|
- **THEN** 网关 SHALL NOT 将上游错误转换为 OpenAI 错误格式
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
- `protocolName()` SHALL 返回 `"openai"`
|
- `protocolName()` SHALL 返回 `"openai"`
|
||||||
- `supportsPassthrough()` SHALL 返回 true
|
- `supportsPassthrough()` SHALL 返回 true
|
||||||
- `buildHeaders(provider)` SHALL 构建 `Authorization: Bearer <api_key>` 和 `Content-Type: application/json`
|
- `buildHeaders(provider)` SHALL 构建 `Authorization: Bearer <api_key>` 和 `Content-Type: application/json`
|
||||||
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射 URL 路径,不带 `/v1` 前缀
|
- `detectInterfaceType(nativePath)` SHALL 识别带 `/v1` 前缀的 OpenAI nativePath
|
||||||
|
- `buildUrl(nativePath, interfaceType)` SHALL 按接口类型映射上游 URL 路径,输出路径不带 `/v1` 前缀
|
||||||
- `supportsInterface()` SHALL 对 CHAT、MODELS、MODEL_INFO、EMBEDDINGS、RERANK 返回 true
|
- `supportsInterface()` SHALL 对 CHAT、MODELS、MODEL_INFO、EMBEDDINGS、RERANK 返回 true
|
||||||
|
|
||||||
#### Scenario: 认证 Header 构建
|
#### Scenario: 认证 Header 构建
|
||||||
@@ -24,12 +25,34 @@
|
|||||||
|
|
||||||
#### Scenario: URL 映射
|
#### Scenario: URL 映射
|
||||||
|
|
||||||
- **WHEN** interfaceType == CHAT
|
- **WHEN** interfaceType == CHAT 且 nativePath 为 `/v1/chat/completions`
|
||||||
- **THEN** SHALL 映射为 `/chat/completions`
|
- **THEN** SHALL 映射为 `/chat/completions`
|
||||||
- **WHEN** interfaceType == MODELS
|
- **WHEN** interfaceType == MODELS 且 nativePath 为 `/v1/models`
|
||||||
- **THEN** SHALL 映射为 `/models`
|
- **THEN** SHALL 映射为 `/models`
|
||||||
- **WHEN** interfaceType == EMBEDDINGS
|
- **WHEN** interfaceType == MODEL_INFO 且 nativePath 为 `/v1/models/{id}`
|
||||||
|
- **THEN** SHALL 映射为 `/models/{id}`
|
||||||
|
- **WHEN** interfaceType == EMBEDDINGS 且 nativePath 为 `/v1/embeddings`
|
||||||
- **THEN** SHALL 映射为 `/embeddings`
|
- **THEN** SHALL 映射为 `/embeddings`
|
||||||
|
- **WHEN** interfaceType == RERANK 且 nativePath 为 `/v1/rerank`
|
||||||
|
- **THEN** SHALL 映射为 `/rerank`
|
||||||
|
|
||||||
|
#### Scenario: 接口类型识别
|
||||||
|
|
||||||
|
- **WHEN** 调用 `DetectInterfaceType("/v1/chat/completions")`
|
||||||
|
- **THEN** SHALL 返回 `InterfaceTypeChat`
|
||||||
|
- **WHEN** 调用 `DetectInterfaceType("/v1/models")`
|
||||||
|
- **THEN** SHALL 返回 `InterfaceTypeModels`
|
||||||
|
- **WHEN** 调用 `DetectInterfaceType("/v1/embeddings")`
|
||||||
|
- **THEN** SHALL 返回 `InterfaceTypeEmbeddings`
|
||||||
|
- **WHEN** 调用 `DetectInterfaceType("/v1/rerank")`
|
||||||
|
- **THEN** SHALL 返回 `InterfaceTypeRerank`
|
||||||
|
|
||||||
|
#### Scenario: 旧无版本路径不再作为已适配接口
|
||||||
|
|
||||||
|
- **WHEN** 调用 `DetectInterfaceType("/chat/completions")`
|
||||||
|
- **THEN** SHALL 返回 `InterfaceTypePassthrough`
|
||||||
|
- **WHEN** 调用 `DetectInterfaceType("/models")`
|
||||||
|
- **THEN** SHALL 返回 `InterfaceTypePassthrough`
|
||||||
|
|
||||||
### Requirement: OpenAI 请求解码(OpenAI → Canonical)
|
### Requirement: OpenAI 请求解码(OpenAI → Canonical)
|
||||||
|
|
||||||
@@ -272,40 +295,40 @@ Encoder SHALL 维护状态:
|
|||||||
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
|
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
|
||||||
### Requirement: 模型详情路径识别
|
### Requirement: 模型详情路径识别
|
||||||
|
|
||||||
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径。
|
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的带 `/v1` 模型详情路径。
|
||||||
|
|
||||||
#### Scenario: 含斜杠的统一模型 ID 路径
|
#### Scenario: 含斜杠的统一模型 ID 路径
|
||||||
|
|
||||||
- **WHEN** 路径为 `/models/openai/gpt-4`
|
- **WHEN** 路径为 `/v1/models/openai/gpt-4`
|
||||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||||
|
|
||||||
#### Scenario: 含多段斜杠的统一模型 ID 路径
|
#### Scenario: 含多段斜杠的统一模型 ID 路径
|
||||||
|
|
||||||
- **WHEN** 路径为 `/models/azure/accounts/org-123/models/gpt-4`
|
- **WHEN** 路径为 `/v1/models/azure/accounts/org-123/models/gpt-4`
|
||||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
|
||||||
|
|
||||||
#### Scenario: 模型列表路径不受影响
|
#### Scenario: 模型列表路径不受影响
|
||||||
|
|
||||||
- **WHEN** 路径为 `/models`
|
- **WHEN** 路径为 `/v1/models`
|
||||||
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
|
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
|
||||||
|
|
||||||
### Requirement: 提取统一模型 ID
|
### Requirement: 提取统一模型 ID
|
||||||
|
|
||||||
OpenAI 适配器 SHALL 从路径中提取统一模型 ID。
|
OpenAI 适配器 SHALL 从带 `/v1` 的模型详情路径中提取统一模型 ID。
|
||||||
|
|
||||||
#### Scenario: 标准路径提取
|
#### Scenario: 标准路径提取
|
||||||
|
|
||||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models/openai/gpt-4")`
|
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/openai/gpt-4")`
|
||||||
- **THEN** SHALL 返回 `"openai/gpt-4"`
|
- **THEN** SHALL 返回 `"openai/gpt-4"`
|
||||||
|
|
||||||
#### Scenario: 复杂路径提取
|
#### Scenario: 复杂路径提取
|
||||||
|
|
||||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models/azure/accounts/org/models/gpt-4")`
|
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")`
|
||||||
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
|
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
|
||||||
|
|
||||||
#### Scenario: 非模型详情路径
|
#### Scenario: 非模型详情路径
|
||||||
|
|
||||||
- **WHEN** 调用 `ExtractUnifiedModelID("/models")`
|
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models")`
|
||||||
- **THEN** SHALL 返回错误
|
- **THEN** SHALL 返回错误
|
||||||
|
|
||||||
### Requirement: 从请求体提取 model
|
### Requirement: 从请求体提取 model
|
||||||
|
|||||||
@@ -6,6 +6,23 @@
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 代理路由入口一致
|
||||||
|
|
||||||
|
server 和 desktop 运行模式 SHALL 使用一致的代理路由入口,确保外部应用在不同运行模式下使用相同的协议 URL。
|
||||||
|
|
||||||
|
#### Scenario: server 代理路由
|
||||||
|
|
||||||
|
- **WHEN** server 模式启动 HTTP 路由
|
||||||
|
- **THEN** SHALL 注册 `/{protocol}/*path` 形式的代理路由
|
||||||
|
- **THEN** `/openai/v1/chat/completions` SHALL 进入 ProxyHandler 并提取 clientProtocol 为 `openai`
|
||||||
|
|
||||||
|
#### Scenario: desktop 代理路由
|
||||||
|
|
||||||
|
- **WHEN** desktop 模式启动 HTTP 路由
|
||||||
|
- **THEN** SHALL 注册显式 `/openai/*path` 和 `/anthropic/*path` 代理路由
|
||||||
|
- **THEN** SHALL 在进入 ProxyHandler 前设置 clientProtocol 参数
|
||||||
|
- **THEN** `/openai/v1/chat/completions` SHALL 进入 ProxyHandler 并提取 clientProtocol 为 `openai`
|
||||||
|
|
||||||
### Requirement: 实现统一代理 Handler
|
### Requirement: 实现统一代理 Handler
|
||||||
|
|
||||||
系统 SHALL 实现统一的 ProxyHandler,替代现有的 OpenAIHandler 和 AnthropicHandler。
|
系统 SHALL 实现统一的 ProxyHandler,替代现有的 OpenAIHandler 和 AnthropicHandler。
|
||||||
@@ -16,13 +33,28 @@ ProxyHandler SHALL 依赖 ConversionEngine、ProviderClient、RoutingService、S
|
|||||||
|
|
||||||
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
|
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
|
||||||
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
|
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
|
||||||
- **THEN** SHALL 剥离前缀得到 nativePath
|
- **THEN** SHALL 只剥离第一段协议前缀得到 nativePath
|
||||||
- **THEN** nativePath SHALL 不添加任何前缀,直接使用 path 参数
|
- **THEN** nativePath SHALL 不添加任何前缀,也不移除协议内部版本段,直接使用 path 参数
|
||||||
|
|
||||||
|
#### Scenario: OpenAI v1 nativePath 保留
|
||||||
|
|
||||||
|
- **WHEN** 收到 `/openai/v1/chat/completions` 请求
|
||||||
|
- **THEN** SHALL 从 URL 第一段提取 `openai` 作为 clientProtocol
|
||||||
|
- **THEN** SHALL 剥离 `/openai` 后得到 nativePath `/v1/chat/completions`
|
||||||
|
- **THEN** SHALL 将 `/v1/chat/completions` 交给 OpenAI adapter 识别
|
||||||
|
|
||||||
|
#### Scenario: Anthropic v1 nativePath 保留
|
||||||
|
|
||||||
|
- **WHEN** 收到 `/anthropic/v1/messages` 请求
|
||||||
|
- **THEN** SHALL 从 URL 第一段提取 `anthropic` 作为 clientProtocol
|
||||||
|
- **THEN** SHALL 剥离 `/anthropic` 后得到 nativePath `/v1/messages`
|
||||||
|
- **THEN** SHALL 将 `/v1/messages` 交给 Anthropic adapter 识别
|
||||||
|
|
||||||
#### Scenario: 协议前缀必须是已注册协议
|
#### Scenario: 协议前缀必须是已注册协议
|
||||||
|
|
||||||
- **WHEN** 收到的 URL 前缀不是已注册的协议名称
|
- **WHEN** 收到的 URL 前缀不是已注册的协议名称
|
||||||
- **THEN** SHALL 返回 404 错误
|
- **THEN** SHALL 返回 404 错误
|
||||||
|
- **THEN** 错误响应 SHALL 使用应用统一错误格式
|
||||||
|
|
||||||
#### Scenario: 接口类型识别
|
#### Scenario: 接口类型识别
|
||||||
|
|
||||||
@@ -37,131 +69,157 @@ ProxyHandler SHALL 按以下流程处理非流式请求。
|
|||||||
#### Scenario: 完整转换流程
|
#### Scenario: 完整转换流程
|
||||||
|
|
||||||
- **WHEN** 收到非流式请求
|
- **WHEN** 收到非流式请求
|
||||||
- **THEN** SHALL 解析请求体为 JSON
|
- **THEN** SHALL 通过客户端 adapter 明确支持的接口规则提取 model
|
||||||
- **THEN** SHALL 调用 RoutingService.Route(modelName) 获取路由结果
|
- **THEN** SHALL 调用 RoutingService.RouteByModelName(providerID, modelName) 获取路由结果
|
||||||
- **THEN** SHALL 从路由结果的 Provider.Protocol 获取 providerProtocol
|
- **THEN** SHALL 从路由结果的 Provider.Protocol 获取 providerProtocol
|
||||||
- **THEN** SHALL 构建 TargetProvider(base_url, api_key, model_name, adapter_config)
|
- **THEN** SHALL 构建 TargetProvider(base_url, api_key, model_name, adapter_config)
|
||||||
- **THEN** SHALL 调用 engine.convertHttpRequest(body, clientProtocol, providerProtocol, provider)
|
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
||||||
- **THEN** SHALL 调用 providerClient.Send(ctx, requestSpec)
|
- **THEN** SHALL 调用 providerClient.Send(ctx, requestSpec)
|
||||||
- **THEN** SHALL 调用 engine.convertHttpResponse(response, clientProtocol, providerProtocol, interfaceType)
|
- **THEN** 若上游响应状态码为 2xx,SHALL 调用 engine.convertHttpResponse 转换响应
|
||||||
- **THEN** SHALL 将转换后的响应返回给客户端
|
- **THEN** SHALL 将转换后的响应返回给客户端
|
||||||
|
|
||||||
#### Scenario: 路由失败处理
|
#### Scenario: 路由失败处理
|
||||||
|
|
||||||
- **WHEN** RoutingService.Route 返回错误
|
- **WHEN** RoutingService.RouteByModelName 返回错误
|
||||||
- **THEN** SHALL 使用 clientProtocol 对应的 Adapter.encodeError 编码错误响应
|
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
|
||||||
- **THEN** SHALL 返回适当的 HTTP 状态码
|
- **THEN** SHALL 返回适当的 HTTP 状态码
|
||||||
|
|
||||||
#### Scenario: 上游请求失败处理
|
#### Scenario: 上游请求失败处理
|
||||||
|
|
||||||
- **WHEN** ProviderClient.Send 返回错误
|
- **WHEN** ProviderClient.Send 未收到上游 HTTP 响应并返回错误
|
||||||
- **THEN** SHALL 使用 clientProtocol 对应的 Adapter.encodeError 编码错误响应
|
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
|
||||||
- **THEN** SHALL 包装原始错误信息
|
- **THEN** SHALL 包装原始错误信息用于日志
|
||||||
|
|
||||||
|
#### Scenario: 上游非 2xx 响应透传
|
||||||
|
|
||||||
|
- **WHEN** ProviderClient.Send 收到上游 HTTP 响应且状态码不是 2xx
|
||||||
|
- **THEN** ProxyHandler SHALL NOT 调用 engine.convertHttpResponse
|
||||||
|
- **THEN** SHALL 透传上游 status code
|
||||||
|
- **THEN** SHALL 透传过滤 hop-by-hop header 后的上游 headers
|
||||||
|
- **THEN** SHALL 透传上游 body
|
||||||
|
|
||||||
### Requirement: 流式请求处理流程
|
### Requirement: 流式请求处理流程
|
||||||
|
|
||||||
ProxyHandler SHALL 按以下流程处理流式请求。
|
ProxyHandler SHALL 按以下流程处理流式请求。
|
||||||
|
|
||||||
#### Scenario: 流式转换流程
|
#### Scenario: 跨协议流式转换流程
|
||||||
|
|
||||||
- **WHEN** 请求中 stream=true 或接口类型为 CHAT 且请求体含 stream:true
|
- **WHEN** 请求中 stream=true 且 clientProtocol != providerProtocol
|
||||||
- **THEN** SHALL 执行与非流式相同的前置处理(路由、构建 TargetProvider)
|
- **THEN** SHALL 执行与非流式相同的前置处理(路由、构建 TargetProvider)
|
||||||
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
|
||||||
- **THEN** SHALL 调用 providerClient.SendStream(ctx, requestSpec) 获取流
|
- **THEN** SHALL 调用 providerClient.SendStream(ctx, requestSpec) 获取流
|
||||||
- **THEN** SHALL 调用 engine.createStreamConverter(clientProtocol, providerProtocol, provider)
|
- **THEN** 若上游返回 2xx 流式响应,SHALL 调用 engine.createStreamConverter(clientProtocol, providerProtocol, provider)
|
||||||
- **THEN** SHALL 设置响应 Header `Content-Type: text/event-stream`
|
- **THEN** SHALL 设置响应 Header `Content-Type: text/event-stream`
|
||||||
- **THEN** SHALL 对每个流 chunk 调用 converter.processChunk 并写入响应
|
- **THEN** SHALL 对每个流 chunk 调用 converter.processChunk 并写入响应
|
||||||
- **THEN** SHALL 在流结束时调用 converter.flush() 刷新缓冲
|
- **THEN** SHALL 在流结束时调用 converter.flush() 刷新缓冲
|
||||||
|
|
||||||
#### Scenario: 同协议透传流式
|
#### Scenario: 同协议 raw 流式透传
|
||||||
|
|
||||||
- **WHEN** clientProtocol == providerProtocol
|
- **WHEN** clientProtocol == providerProtocol 且响应不需要 model 改写
|
||||||
- **THEN** SHALL 直接将上游 SSE 字节流写入响应
|
- **THEN** SHALL 直接将上游 SSE 字节流写入响应
|
||||||
|
- **THEN** SHALL 保留 SSE frame 边界和结束标记
|
||||||
- **THEN** SHALL NOT 做任何解析或转换
|
- **THEN** SHALL NOT 做任何解析或转换
|
||||||
|
|
||||||
|
#### Scenario: 同协议流式 Smart Passthrough
|
||||||
|
|
||||||
|
- **WHEN** clientProtocol == providerProtocol 且响应需要 model 改写
|
||||||
|
- **THEN** SHALL 按 SSE frame 读取上游流
|
||||||
|
- **THEN** SHALL 仅改写 `data` JSON payload 内的 model 字段
|
||||||
|
- **THEN** SHALL 原样保留 `[DONE]` 结束标记
|
||||||
|
- **THEN** SHALL 重建合法 SSE frame 写回客户端
|
||||||
|
- **THEN** SHALL NOT 做 Canonical decode/encode 转换
|
||||||
|
|
||||||
|
#### Scenario: 流式上游非 2xx 响应透传
|
||||||
|
|
||||||
|
- **WHEN** 流式请求收到上游非 2xx HTTP 响应
|
||||||
|
- **THEN** SHALL NOT 创建 StreamConverter
|
||||||
|
- **THEN** SHALL 透传上游 status code、过滤后的 headers 和 body
|
||||||
|
|
||||||
#### Scenario: 流式错误处理
|
#### Scenario: 流式错误处理
|
||||||
|
|
||||||
- **WHEN** 流过程中发生错误
|
- **WHEN** 流过程中发生网关层读取或写入错误
|
||||||
- **THEN** SHALL 记录错误日志
|
- **THEN** SHALL 记录错误日志
|
||||||
- **THEN** SHALL 关闭响应流
|
- **THEN** SHALL 关闭响应流
|
||||||
|
|
||||||
### Requirement: 统计记录
|
|
||||||
|
|
||||||
ProxyHandler SHALL 记录请求统计。
|
|
||||||
|
|
||||||
#### Scenario: 异步记录统计
|
|
||||||
|
|
||||||
- **WHEN** 请求处理完成(成功或失败)
|
|
||||||
- **THEN** SHALL 异步调用 StatsService.Record
|
|
||||||
- **THEN** SHALL NOT 阻塞响应返回
|
|
||||||
|
|
||||||
### Requirement: GET 请求透传
|
### Requirement: GET 请求透传
|
||||||
|
|
||||||
ProxyHandler SHALL 支持 GET 请求的扩展层接口代理。
|
ProxyHandler SHALL 支持无请求体 GET 请求和未知接口透传。
|
||||||
|
|
||||||
#### Scenario: Models 接口<EFBFBD><EFBFBD>理
|
#### Scenario: Models 接口本地处理
|
||||||
|
|
||||||
|
- **WHEN** 收到客户端协议的模型列表路径请求,例如 `GET /openai/v1/models` 或 `GET /anthropic/v1/models`
|
||||||
|
- **THEN** SHALL 走模型列表本地聚合流程
|
||||||
|
- **THEN** SHALL NOT 请求上游供应商
|
||||||
|
|
||||||
|
#### Scenario: 未知 GET 接口透传
|
||||||
|
|
||||||
|
- **WHEN** 收到未被 adapter 识别为本地处理接口的 GET 请求
|
||||||
|
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
||||||
|
- **THEN** SHALL 保留原始 query string
|
||||||
|
|
||||||
- **WHEN** 收到 GET /{protocol}/v1/models 请求
|
|
||||||
- **THEN** SHALL 执行路由和协议识别
|
|
||||||
- **THEN** SHALL 调用 engine.convertHttpRequest(GET 请求 body 为空)
|
|
||||||
- **THEN** SHALL 调用 providerClient.Send 发送请求
|
|
||||||
- **THEN** SHALL 调用 engine.convertHttpResponse 转换响应格式
|
|
||||||
- **THEN** SHALL 返回转换后的响应
|
|
||||||
### Requirement: 代理请求路由
|
### Requirement: 代理请求路由
|
||||||
|
|
||||||
ProxyHandler SHALL 使用统一模型 ID 路由所有代理请求。
|
ProxyHandler SHALL 使用统一模型 ID 路由 adapter 明确支持提取 model 的代理请求。
|
||||||
|
|
||||||
#### Scenario: 提取统一模型 ID
|
#### Scenario: 提取统一模型 ID
|
||||||
|
|
||||||
- **WHEN** 收到 Chat、Embeddings 或 Rerank 接口的 POST 请求(含请求体)
|
- **WHEN** 收到 adapter 明确支持提取 model 的接口请求(如 Chat、Embeddings 或 Rerank)且请求体非空
|
||||||
- **THEN** SHALL 调用客户端协议 adapter 的 `ExtractModelName(body, ifaceType)` 提取 model 值
|
- **THEN** SHALL 调用客户端协议 adapter 的 `ExtractModelName(body, ifaceType)` 提取 model 值
|
||||||
- **THEN** SHALL 调用 `ParseUnifiedModelID` 解析得到 providerID 和 modelName
|
- **THEN** SHALL 调用 `ParseUnifiedModelID` 解析得到 providerID 和 modelName
|
||||||
- **THEN** SHALL 调用 `RoutingService.RouteByModelName(providerID, modelName)` 路由
|
- **THEN** SHALL 调用 `RoutingService.RouteByModelName(providerID, modelName)` 路由
|
||||||
|
|
||||||
|
#### Scenario: 未知接口不猜测 model
|
||||||
|
|
||||||
|
- **WHEN** 收到 adapter 未明确支持提取 model 的接口请求
|
||||||
|
- **THEN** ProxyHandler SHALL NOT 尝试通用解析顶层 model 字段
|
||||||
|
- **THEN** SHALL 按无 model 请求走 forwardPassthrough
|
||||||
|
|
||||||
#### Scenario: GET 请求或无请求体
|
#### Scenario: GET 请求或无请求体
|
||||||
|
|
||||||
- **WHEN** 收到 GET 请求或请求体为空或请求体中无法提取 model 字段
|
- **WHEN** 收到 GET 请求或请求体为空或请求体中无法提取 model 字段
|
||||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容未适配的客户端和无 body 请求)
|
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
||||||
|
|
||||||
#### Scenario: 无效的统一模型 ID
|
#### Scenario: 无效的统一模型 ID
|
||||||
|
|
||||||
- **WHEN** 请求体中 `model` 字段不是有效的统一模型 ID 格式(不含 `/`)
|
- **WHEN** adapter 已提取 model 字段但该字段不是有效的统一模型 ID 格式(不含 `/`)
|
||||||
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容使用原始模型名的客户端)
|
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
|
||||||
|
|
||||||
#### Scenario: 模型不存在
|
#### Scenario: 模型不存在
|
||||||
|
|
||||||
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的 provider_id + model_name 组合
|
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的 provider_id + model_name 组合
|
||||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||||
|
|
||||||
#### Scenario: 模型已禁用
|
#### Scenario: 模型已禁用
|
||||||
|
|
||||||
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
|
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
|
||||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||||
|
|
||||||
#### Scenario: 供应商已禁用
|
#### Scenario: 供应商已禁用
|
||||||
|
|
||||||
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
|
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
|
||||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||||
|
|
||||||
### Requirement: 同协议 Smart Passthrough
|
### Requirement: 同协议 Smart Passthrough
|
||||||
|
|
||||||
当客户端协议与供应商协议相同时,ProxyHandler SHALL 使用 Smart Passthrough 处理 Chat、Embedding、Rerank 请求。
|
当客户端协议与供应商协议相同时,ProxyHandler SHALL 使用 Smart Passthrough 处理 adapter 明确支持的 Chat、Embedding、Rerank 请求。
|
||||||
|
|
||||||
#### Scenario: 同协议非流式请求
|
#### Scenario: 同协议非流式请求
|
||||||
|
|
||||||
- **WHEN** 客户端协议 == 供应商协议,且为非流式请求
|
- **WHEN** 客户端协议 == 供应商协议,且为非流式请求
|
||||||
- **THEN** SHALL 调用 adapter 的 `RewriteRequestModelName(body, modelName, ifaceType)` 将请求体中 model 从统一 ID 改写为上游模型名
|
- **THEN** SHALL 调用 adapter 的 `RewriteRequestModelName(body, modelName, ifaceType)` 将请求体中 model 从统一 ID 改写为上游模型名
|
||||||
- **THEN** SHALL 构建 URL 和 Headers(同当前透传逻辑)
|
- **THEN** SHALL 使用 providerAdapter.BuildUrl(nativePath, ifaceType) 构建上游 URL 路径
|
||||||
|
- **THEN** SHALL 使用 providerAdapter.BuildHeaders(provider) 构建 Headers
|
||||||
- **THEN** SHALL 发送改写后的请求体到上游
|
- **THEN** SHALL 发送改写后的请求体到上游
|
||||||
- **THEN** SHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
|
- **THEN** 若上游返回 2xx,SHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
|
||||||
- **THEN** SHALL NOT 对 body 做全量 decode → encode,保持未改写字段的原始 bytes
|
- **THEN** SHALL NOT 对 body 做 Canonical 全量 decode → encode,保持未改写字段的 JSON 内容和类型不变,但不承诺保留原始字节顺序或空白
|
||||||
|
|
||||||
#### Scenario: 同协议流式请求
|
#### Scenario: 同协议流式请求
|
||||||
|
|
||||||
- **WHEN** 客户端协议 == 供应商协议,且为流式请求
|
- **WHEN** 客户端协议 == 供应商协议,且为流式请求
|
||||||
- **THEN** SHALL 对请求体做 `RewriteRequestModelName` 改写 model 字段
|
- **THEN** SHALL 对请求体做 `RewriteRequestModelName` 改写 model 字段
|
||||||
- **THEN** SHALL 逐 SSE chunk 调用 `RewriteResponseModelName` 改写响应中 model 字段
|
- **THEN** SHALL 使用 providerAdapter.BuildUrl(nativePath, ifaceType) 构建上游 URL 路径
|
||||||
- **THEN** SHALL NOT 对 chunk 做全量 decode → encode
|
- **THEN** SHALL 按 raw passthrough 或 SSE frame 级 Smart Passthrough 处理响应流
|
||||||
|
- **THEN** SHALL NOT 对 chunk 做 Canonical 全量 decode → encode
|
||||||
|
|
||||||
#### Scenario: Smart Passthrough 保真性
|
#### Scenario: Smart Passthrough 保真性
|
||||||
|
|
||||||
@@ -196,6 +254,21 @@ ProxyHandler SHALL 从数据库聚合返回模型列表,不再透传上游。
|
|||||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||||
- **THEN** SHALL NOT 请求上游供应商
|
- **THEN** SHALL NOT 请求上游供应商
|
||||||
|
|
||||||
|
#### Scenario: OpenAI Models 本地聚合
|
||||||
|
|
||||||
|
- **WHEN** 收到 `GET /openai/v1/models` 请求
|
||||||
|
- **THEN** nativePath SHALL 为 `/v1/models`
|
||||||
|
- **THEN** OpenAI adapter SHALL 将接口类型识别为 `InterfaceTypeModels`
|
||||||
|
- **THEN** ProxyHandler SHALL 从数据库聚合返回 OpenAI Models 格式响应
|
||||||
|
|
||||||
|
#### Scenario: 客户端协议模型列表路径
|
||||||
|
|
||||||
|
- **WHEN** 收到客户端协议的模型列表路径请求,例如 `GET /openai/v1/models` 或 `GET /anthropic/v1/models`
|
||||||
|
- **THEN** SHALL 从数据库查询所有 enabled 的模型(关联 enabled 的供应商)
|
||||||
|
- **THEN** SHALL 组装 `CanonicalModelList`,每个模型的 ID 字段为统一模型 ID(`provider_id/model_name`),Name 字段为 model_name,OwnedBy 字段为 provider_id
|
||||||
|
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||||
|
- **THEN** SHALL NOT 请求上游供应商
|
||||||
|
|
||||||
#### Scenario: 无可用模型
|
#### Scenario: 无可用模型
|
||||||
|
|
||||||
- **WHEN** 数据库中没有 enabled 的模型
|
- **WHEN** 数据库中没有 enabled 的模型
|
||||||
@@ -215,10 +288,27 @@ ProxyHandler SHALL 从数据库查询返回模型详情,不再透传上游。
|
|||||||
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||||
- **THEN** SHALL NOT 请求上游供应商
|
- **THEN** SHALL NOT 请求上游供应商
|
||||||
|
|
||||||
|
#### Scenario: OpenAI ModelInfo 本地查询
|
||||||
|
|
||||||
|
- **WHEN** 收到 `GET /openai/v1/models/openai/gpt-4` 请求
|
||||||
|
- **THEN** nativePath SHALL 为 `/v1/models/openai/gpt-4`
|
||||||
|
- **THEN** OpenAI adapter SHALL 提取统一模型 ID `openai/gpt-4`
|
||||||
|
- **THEN** ProxyHandler SHALL 从数据库查询模型详情并返回 OpenAI ModelInfo 格式响应
|
||||||
|
|
||||||
|
#### Scenario: 客户端协议模型详情路径
|
||||||
|
|
||||||
|
- **WHEN** 收到客户端协议的模型详情路径请求,例如 `GET /openai/v1/models/{provider_id}/{model_name}` 或 `GET /anthropic/v1/models/{provider_id}/{model_name}`
|
||||||
|
- **THEN** SHALL 调用 adapter 的 `ExtractUnifiedModelID` 提取统一模型 ID
|
||||||
|
- **THEN** SHALL 解析统一模型 ID 得到 providerID 和 modelName
|
||||||
|
- **THEN** SHALL 从数据库查询对应的模型和供应商
|
||||||
|
- **THEN** SHALL 组装 `CanonicalModelInfo`,ID 字段为统一模型 ID(`provider_id/model_name`),Name 字段为 model_name,OwnedBy 字段为 provider_id
|
||||||
|
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
|
||||||
|
- **THEN** SHALL NOT 请求上游供应商
|
||||||
|
|
||||||
#### Scenario: 模型详情不存在
|
#### Scenario: 模型详情不存在
|
||||||
|
|
||||||
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
|
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
|
||||||
- **THEN** SHALL 返回错误响应,状态码为 404
|
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||||||
|
|
||||||
### Requirement: 统计记录
|
### Requirement: 统计记录
|
||||||
|
|
||||||
@@ -228,3 +318,4 @@ ProxyHandler SHALL 使用 providerID 和 modelName 记录使用统计。
|
|||||||
|
|
||||||
- **WHEN** 代理请求成功完成
|
- **WHEN** 代理请求成功完成
|
||||||
- **THEN** SHALL 异步调用 `StatsService.Record(providerID, modelName)`
|
- **THEN** SHALL 异步调用 `StatsService.Record(providerID, modelName)`
|
||||||
|
- **THEN** SHALL NOT 阻塞响应返回
|
||||||
|
|||||||
Reference in New Issue
Block a user