322 lines
14 KiB
Markdown
322 lines
14 KiB
Markdown
# Unified Proxy Handler
|
||
|
||
## Purpose
|
||
|
||
实现统一的代理处理器(ProxyHandler),替代独立的 OpenAI 和 Anthropic Handler,通过 ConversionEngine 和 RoutingService 支持多协议的请求转换与转发。
|
||
|
||
## 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。
|
||
|
||
ProxyHandler SHALL 依赖 ConversionEngine、ProviderClient、RoutingService、StatsService。
|
||
|
||
#### Scenario: 从 URL 提取客户端协议
|
||
|
||
- **WHEN** 收到 `/{protocol}/{path}` 格式的请求
|
||
- **THEN** SHALL 从 URL 第一段提取 protocol 作为 clientProtocol
|
||
- **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: 接口类型识别
|
||
|
||
- **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/v1/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** 客户端协议 != 供应商协议
|
||
- **THEN** SHALL 走 `ConvertHttpRequest` 全量转换,encoder 中 provider.ModelName 覆盖 model
|
||
- **THEN** SHALL 走 `ConvertHttpResponse` 全量转换,modelOverride 参数覆写 canonical.Model
|
||
|
||
#### Scenario: 跨协议流式请求
|
||
|
||
- **WHEN** 客户端协议 != 供应商协议,且为流式请求
|
||
- **THEN** SHALL 走 `CreateStreamConverter` 全量转换,modelOverride 参数覆写流式 canonical 事件中的 Model
|
||
|
||
### Requirement: 模型列表本地聚合
|
||
|
||
ProxyHandler SHALL 从数据库聚合返回模型列表,不再透传上游。
|
||
|
||
#### Scenario: GET /v1/models
|
||
|
||
- **WHEN** 收到 `GET /{protocol}/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: 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: 无可用模型
|
||
|
||
- **WHEN** 数据库中没有 enabled 的模型
|
||
- **THEN** SHALL 返回空列表
|
||
|
||
### Requirement: 模型详情本地查询
|
||
|
||
ProxyHandler SHALL 从数据库查询返回模型详情,不再透传上游。
|
||
|
||
#### Scenario: GET /v1/models/{unified_id}
|
||
|
||
- **WHEN** 收到 `GET /{protocol}/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: 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: 模型详情不存在
|
||
|
||
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
|
||
- **THEN** SHALL 返回应用统一错误响应,状态码为 404
|
||
|
||
### Requirement: 统计记录
|
||
|
||
ProxyHandler SHALL 使用 providerID 和 modelName 记录使用统计。
|
||
|
||
#### Scenario: 异步记录统计
|
||
|
||
- **WHEN** 代理请求成功完成
|
||
- **THEN** SHALL 异步调用 `StatsService.Record(providerID, modelName)`
|
||
- **THEN** SHALL NOT 阻塞响应返回
|