1
0

fix: 完善转换代理行为

This commit is contained in:
2026-04-26 21:48:17 +08:00
parent 155244433f
commit 9622d44aac
33 changed files with 1127 additions and 1117 deletions

View File

@@ -13,22 +13,24 @@
#### Scenario: 成功的非流式请求
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式)
- **THEN** 网关 SHALL 剥离 `/anthropic` 前缀并将 `/v1/messages` 作为 Anthropic nativePath
- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式
- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
- **THEN** 若上游返回 2xx网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用
#### Scenario: 成功的流式请求
- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true`
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式
- **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 转换,仅重建认证 Header 后原样转发
- **THEN** 请求和响应 Body SHALL 保持原样
- **THEN** 网关 SHALL 跳过 Canonical 转换
- **THEN** 网关 SHALL 使用 Anthropic adapter 重建上游 URL 和认证 Header
- **THEN** 若请求使用统一模型 ID网关 SHALL 仅通过 Smart Passthrough 改写 model 字段
### Requirement: 双向协议转换
@@ -39,7 +41,7 @@
- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议
- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest
- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest
- **THEN** SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
- **THEN** 若上游返回 2xxSHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse
- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse
#### Scenario: OpenAI 客户端 → Anthropic 供应商
@@ -47,12 +49,18 @@
- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议
- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest
- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest
- **THEN** SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse
- **THEN** 若上游返回 2xxSHALL 将 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` 层级以兼容 Claude Code
Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以匹配 Anthropic 原生路径约定
#### Scenario: Claude Code 调用 Anthropic 端点
@@ -60,3 +68,17 @@ Anthropic 协议的对外端点 SHALL 保持 `/v1` 层级以兼容 Claude Code
- **THEN** 网关 SHALL 正确处理请求
- **THEN** nativePath SHALL 为 `/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`

View File

@@ -325,3 +325,137 @@ TargetProvider 的 ModelName 字段 SHALL 存储上游供应商的模型名称
- **WHEN** 协议适配器编码请求时
- **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`

View File

@@ -73,21 +73,46 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
#### Scenario: API 请求路由
- **WHEN** 请求路径以 `/api/``/v1/` 开头
- **THEN** 请求由现有业务 handler 处理
- **WHEN** 请求路径以 `/api/``/health` 开头
- **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: 静态资源路由
- **WHEN** 请求路径为 `/assets/*`
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: Favicon 路由
- **WHEN** 请求路径为 `/favicon.svg`
- **THEN** 返回嵌入的前端 favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退
- **WHEN** 请求路径不匹配任何 API 或静态资源路由
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
### Requirement: 端口冲突检测

View File

@@ -23,6 +23,70 @@
- **THEN** `error` 字段 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: 前端提取并处理错误码
前端 SHALL 提取后端结构化错误响应中的错误码并用于错误处理。
@@ -212,7 +276,7 @@
#### Scenario: 请求体 JSON 格式错误
- **WHEN** 代理请求的请求体不是有效的 JSON 格式
- **WHEN** 代理请求的请求体不是有效的 JSON 格式,且该接口需要网关解析请求体
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json

View File

@@ -2,33 +2,66 @@
## Purpose
定义 OpenAI Chat Completions API 端点的协议代理行为,包括请求处理流程、模型路由、同协议透传和跨协议转换。
定义 OpenAI Chat Completions API 端点及扩展层端点的协议代理行为,包括请求处理流程、模型路由、同协议透传和跨协议转换。
## 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 端点
网关 SHALL 提供 OpenAI Chat Completions API 端点供外部应用调用。
网关 SHALL 提供`/v1` 层级的 OpenAI Chat Completions API 端点供外部应用调用。
#### 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 将转换后的请求转发到配置的供应商
- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
- **THEN** 若上游返回 2xx网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用
#### Scenario: 成功的流式请求
- **WHEN** 应用发送 POST 请求到 `/openai/chat/completions`,携带 `stream: true`
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter
- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带 `stream: true`
- **THEN** 网关 SHALL 剥离 `/openai` 前缀并将 `/v1/chat/completions` 作为 OpenAI nativePath
- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter 或使用同协议流式透传路径
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用
- **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]`
- **THEN** 网关 SHALL 在 OpenAI 协议流完成时发送或透传 `data: [DONE]`
#### Scenario: 同协议透传OpenAI → OpenAI Provider
- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议
- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发
- **THEN** 请求和响应 Body SHALL 保持原样
- **THEN** 网关 SHALL 跳过 Canonical 转换
- **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: 根据模型名称路由请求
@@ -36,20 +69,25 @@
#### Scenario: 有效模型路由
- **WHEN** 请求包含存在于配置模型中`model` 字段
- **AND** 该模型已启用
- **WHEN** 请求包含有效统一模型 ID 格式`model` 字段
- **AND** 该模型存在且已启用
- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商
- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol
#### Scenario: 模型未找到
- **WHEN** 请求包含不存在于配置模型中`model` 字段
- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应
- **WHEN** 请求包含有效统一模型 ID 格式`model` 字段但模型不存在
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
#### Scenario: 模型已禁用
- **WHEN** 请求包含已禁用模型的 `model` 字段
- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应
- **WHEN** 请求包含已禁用模型的有效统一模型 ID
- **THEN** 网关 SHALL 使用应用统一错误格式返回 404 错误
#### Scenario: 原始模型名兼容透传
- **WHEN** 请求中的 `model` 字段不是有效统一模型 ID 格式
- **THEN** 网关 SHALL 按无可路由 model 请求走 forwardPassthrough
### Requirement: 跨协议请求转换
@@ -60,8 +98,16 @@
- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议
- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式
- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header
- **THEN** 目标协议 Adapter SHALL 基于接口类型构建目标协议路径,不直接复用 OpenAI nativePath 中的 `/v1` 版本段
#### Scenario: 扩展层接口代理
- **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 错误格式

View File

@@ -11,7 +11,8 @@
- `protocolName()` SHALL 返回 `"openai"`
- `supportsPassthrough()` SHALL 返回 true
- `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
#### Scenario: 认证 Header 构建
@@ -24,12 +25,34 @@
#### Scenario: URL 映射
- **WHEN** interfaceType == CHAT
- **WHEN** interfaceType == CHAT 且 nativePath 为 `/v1/chat/completions`
- **THEN** SHALL 映射为 `/chat/completions`
- **WHEN** interfaceType == MODELS
- **WHEN** interfaceType == MODELS 且 nativePath 为 `/v1/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`
- **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
@@ -272,40 +295,40 @@ Encoder SHALL 维护状态:
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
### Requirement: 模型详情路径识别
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径。
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的`/v1` 模型详情路径。
#### Scenario: 含斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/models/openai/gpt-4`
- **WHEN** 路径为 `/v1/models/openai/gpt-4`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### 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`
#### Scenario: 模型列表路径不受影响
- **WHEN** 路径为 `/models`
- **WHEN** 路径为 `/v1/models`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
### Requirement: 提取统一模型 ID
OpenAI 适配器 SHALL 从路径中提取统一模型 ID。
OpenAI 适配器 SHALL 从`/v1` 的模型详情路径中提取统一模型 ID。
#### Scenario: 标准路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/models/openai/gpt-4")`
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/openai/gpt-4")`
- **THEN** SHALL 返回 `"openai/gpt-4"`
#### 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"`
#### Scenario: 非模型详情路径
- **WHEN** 调用 `ExtractUnifiedModelID("/models")`
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models")`
- **THEN** SHALL 返回错误
### Requirement: 从请求体提取 model

View File

@@ -6,6 +6,23 @@
## 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
系统 SHALL 实现统一的 ProxyHandler替代现有的 OpenAIHandler 和 AnthropicHandler。
@@ -16,13 +33,28 @@ ProxyHandler SHALL 依赖 ConversionEngine、ProviderClient、RoutingService、S
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
- **THEN** SHALL 剥离前缀得到 nativePath
- **THEN** nativePath SHALL 不添加任何前缀,直接使用 path 参数
- **THEN** SHALL 剥离第一段协议前缀得到 nativePath
- **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: 协议前缀必须是已注册协议
- **WHEN** 收到的 URL 前缀不是已注册的协议名称
- **THEN** SHALL 返回 404 错误
- **THEN** 错误响应 SHALL 使用应用统一错误格式
#### Scenario: 接口类型识别
@@ -37,131 +69,157 @@ ProxyHandler SHALL 按以下流程处理非流式请求。
#### Scenario: 完整转换流程
- **WHEN** 收到非流式请求
- **THEN** SHALL 解析请求体为 JSON
- **THEN** SHALL 调用 RoutingService.Route(modelName) 获取路由结果
- **THEN** SHALL 通过客户端 adapter 明确支持的接口规则提取 model
- **THEN** SHALL 调用 RoutingService.RouteByModelName(providerID, modelName) 获取路由结果
- **THEN** SHALL 从路由结果的 Provider.Protocol 获取 providerProtocol
- **THEN** SHALL 构建 TargetProviderbase_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 调用 engine.convertHttpResponse(response, clientProtocol, providerProtocol, interfaceType)
- **THEN** 若上游响应状态码为 2xxSHALL 调用 engine.convertHttpResponse 转换响应
- **THEN** SHALL 将转换后的响应返回给客户端
#### Scenario: 路由失败处理
- **WHEN** RoutingService.Route 返回错误
- **THEN** SHALL 使用 clientProtocol 对应的 Adapter.encodeError 编码错误响应
- **WHEN** RoutingService.RouteByModelName 返回错误
- **THEN** SHALL 使用应用统一错误格式返回网关层错误
- **THEN** SHALL 返回适当的 HTTP 状态码
#### Scenario: 上游请求失败处理
- **WHEN** ProviderClient.Send 返回错误
- **THEN** SHALL 使用 clientProtocol 对应的 Adapter.encodeError 编码错误响应
- **THEN** SHALL 包装原始错误信息
- **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: 流式转换流程
#### Scenario: 跨协议流式转换流程
- **WHEN** 请求中 stream=true 或接口类型为 CHAT 且请求体含 stream:true
- **WHEN** 请求中 stream=true 且 clientProtocol != providerProtocol
- **THEN** SHALL 执行与非流式相同的前置处理(路由、构建 TargetProvider
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
- **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 对每个流 chunk 调用 converter.processChunk 并写入响应
- **THEN** SHALL 在流结束时调用 converter.flush() 刷新缓冲
#### Scenario: 同协议透传流式
#### Scenario: 同协议 raw 流式透传
- **WHEN** clientProtocol == providerProtocol
- **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** 流过程中发生错误
- **WHEN** 流过程中发生网关层读取或写入错误
- **THEN** SHALL 记录错误日志
- **THEN** SHALL 关闭响应流
### Requirement: 统计记录
ProxyHandler SHALL 记录请求统计。
#### Scenario: 异步记录统计
- **WHEN** 请求处理完成(成功或失败)
- **THEN** SHALL 异步调用 StatsService.Record
- **THEN** SHALL NOT 阻塞响应返回
### 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.convertHttpRequestGET 请求 body 为空)
- **THEN** SHALL 调用 providerClient.Send 发送请求
- **THEN** SHALL 调用 engine.convertHttpResponse 转换响应格式
- **THEN** SHALL 返回转换后的响应
### Requirement: 代理请求路由
ProxyHandler SHALL 使用统一模型 ID 路由所有代理请求。
ProxyHandler SHALL 使用统一模型 ID 路由 adapter 明确支持提取 model 的代理请求。
#### Scenario: 提取统一模型 ID
- **WHEN** 收到 Chat、Embeddings 或 Rerank 接口的 POST 请求(含请求体
- **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 透传到上游供应商(兼容未适配的客户端和无 body 请求)
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
#### Scenario: 无效的统一模型 ID
- **WHEN** 请求体中 `model` 字段不是有效的统一模型 ID 格式(不含 `/`
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容使用原始模型名的客户端)
- **WHEN** adapter 已提取 model 字段但该字段不是有效的统一模型 ID 格式(不含 `/`
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商
#### Scenario: 模型不存在
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的 provider_id + model_name 组合
- **THEN** SHALL 返回错误响应,状态码为 404
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
#### Scenario: 模型已禁用
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
- **THEN** SHALL 返回错误响应,状态码为 404
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
#### Scenario: 供应商已禁用
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
- **THEN** SHALL 返回错误响应,状态码为 404
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
### Requirement: 同协议 Smart Passthrough
当客户端协议与供应商协议相同时ProxyHandler SHALL 使用 Smart Passthrough 处理 Chat、Embedding、Rerank 请求。
当客户端协议与供应商协议相同时ProxyHandler SHALL 使用 Smart Passthrough 处理 adapter 明确支持的 Chat、Embedding、Rerank 请求。
#### Scenario: 同协议非流式请求
- **WHEN** 客户端协议 == 供应商协议,且为非流式请求
- **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 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
- **THEN** SHALL NOT 对 body 做全量 decode → encode保持未改写字段的原始 bytes
- **THEN** 若上游返回 2xxSHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
- **THEN** SHALL NOT 对 body 做 Canonical 全量 decode → encode保持未改写字段的 JSON 内容和类型不变,但不承诺保留原始字节顺序或空白
#### Scenario: 同协议流式请求
- **WHEN** 客户端协议 == 供应商协议,且为流式请求
- **THEN** SHALL 对请求体做 `RewriteRequestModelName` 改写 model 字段
- **THEN** SHALL 逐 SSE chunk 调用 `RewriteResponseModelName` 改写响应中 model 字段
- **THEN** SHALL NOT 对 chunk 做全量 decode → encode
- **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 保真性
@@ -196,6 +254,21 @@ ProxyHandler SHALL 从数据库聚合返回模型列表,不再透传上游。
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
- **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_nameOwnedBy 字段为 provider_id
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
- **THEN** SHALL NOT 请求上游供应商
#### Scenario: 无可用模型
- **WHEN** 数据库中没有 enabled 的模型
@@ -215,10 +288,27 @@ ProxyHandler SHALL 从数据库查询返回模型详情,不再透传上游。
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
- **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_nameOwnedBy 字段为 provider_id
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
- **THEN** SHALL NOT 请求上游供应商
#### Scenario: 模型详情不存在
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
- **THEN** SHALL 返回错误响应,状态码为 404
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
### Requirement: 统计记录
@@ -228,3 +318,4 @@ ProxyHandler SHALL 使用 providerID 和 modelName 记录使用统计。
- **WHEN** 代理请求成功完成
- **THEN** SHALL 异步调用 `StatsService.Record(providerID, modelName)`
- **THEN** SHALL NOT 阻塞响应返回