1
0
Files
nex/openspec/specs/unified-proxy-handler/spec.md

322 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 构建 TargetProviderbase_url, api_key, model_name, adapter_config
- **THEN** SHALL 调用 engine.convertHttpRequest 获取 HTTPRequestSpec
- **THEN** SHALL 调用 providerClient.Send(ctx, requestSpec)
- **THEN** 若上游响应状态码为 2xxSHALL 调用 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** 若上游返回 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 使用 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_nameOwnedBy 字段为 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_nameOwnedBy 字段为 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_nameOwnedBy 字段为 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_nameOwnedBy 字段为 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 阻塞响应返回