docs: 添加 openspec 变更记录,归档已完成变更并添加 unified-model-id 提案
This commit is contained in:
137
openspec/changes/unified-model-id/design.md
Normal file
137
openspec/changes/unified-model-id/design.md
Normal file
@@ -0,0 +1,137 @@
|
||||
## Context
|
||||
|
||||
Nex 是一个 AI 网关,屏蔽多个 AI 供应商(OpenAI、Anthropic 等)的差异,提供统一的 API 接口。当前后端直接透传上游供应商的原始模型名称(如 `gpt-4`),通过 `models` 表的 `model_name` 字段路由。`models` 表的 `id` 字段当前语义是用户自定义标识符,与上游模型名 `model_name` 之间没有明确的职责分离。
|
||||
|
||||
当前架构:
|
||||
- `ProxyHandler` 从请求体中提取 `model` 字段 → `RoutingService.Route(modelName)` 按 `model_name` 查询
|
||||
- `GET /v1/models` 直接透传到第一个供应商的上游接口
|
||||
- `GET /v1/models/{id}` 直接透传到上游
|
||||
- `TargetProvider.ModelName` 在 encoder 中覆盖请求体的 `model` 字段
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 定义统一模型 ID 格式 `provider_id/model_name`,全局唯一标识一个模型
|
||||
- 拦截 `/v1/models` 和 `/v1/models/{unified_id}` 接口,从数据库聚合返回,不再透传上游
|
||||
- 所有代理接口(Chat、Embeddings、Rerank)使用统一模型 ID 路由,响应中 `model` 字段覆写为统一 ID
|
||||
- `models.id` 改为 UUID(内部标识),`models.model_name` 存储上游供应商的模型名称
|
||||
- `provider_id` 约束为 `[a-zA-Z0-9_]+`,防止特殊字符影响 URL 和 JSON 交互
|
||||
- 保持协议无关、供应商无关的设计
|
||||
|
||||
**Non-Goals:**
|
||||
- 不支持供应商别名或模型别名
|
||||
- 不做上游模型列表自动同步(管理员手动配置可见模型)
|
||||
- 不适配前端(后续统一适配)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: 统一模型 ID 格式 — `provider_id/model_name`
|
||||
|
||||
格式: `{provider_id}/{model_name}`,例如 `openai/gpt-4`、`anthropic/claude-3-opus-20240229`。
|
||||
|
||||
- 使用 `strings.SplitN(id, "/", 2)` 解析,只在第一个 `/` 处分割
|
||||
- `provider_id` 约束为 `[a-zA-Z0-9_]+`,保证不含 `/`,解析安全
|
||||
- `model_name`(上游模型名)不受字符约束,因为它不出现在管理 API 的 URL 主键中
|
||||
|
||||
选择此格式而非 `provider_id:model_name`(冒号分隔)的原因:斜杠在 JSON 字符串中天然安全,且在 URL 路径中语义清晰(`/v1/models/openai/gpt-4`),更符合 REST 风格。
|
||||
|
||||
### D2: models 表 schema 变更
|
||||
|
||||
```
|
||||
旧: id(TEXT PK, 用户自定义), provider_id, model_name(上游模型名), enabled, created_at
|
||||
新: id(UUID PK, 自动生成), provider_id, model_name(上游模型名), enabled, created_at
|
||||
UNIQUE(provider_id, model_name)
|
||||
```
|
||||
|
||||
关键语义变化:
|
||||
- `id` 从用户自定义标识符变为 UUID 内部主键(自动生成),用于管理接口 CRUD
|
||||
- `model_name` 语义不变,始终存储上游供应商的模型名称,发给上游的实际值
|
||||
- 新增联合唯一约束 `UNIQUE(provider_id, model_name)` 保证同一供应商内模型不重复
|
||||
|
||||
选择保留 `id` 作为 PK 而非使用 `(provider_id, model_name)` 联合主键的原因:上游模型名可能含 `/` 等特殊字符(如 Azure OpenAI 的 deployment 路径),不适合作为管理接口的 URL 参数。`id` 为 UUID 可以避免所有特殊字符问题。
|
||||
|
||||
### D3: Models/ModelInfo 接口本地聚合
|
||||
|
||||
`GET /v1/models` 从数据库查询所有 `enabled` 的模型(JOIN providers),组装为 `CanonicalModelList`,`ID` 字段使用统一模型 ID,通过客户端协议的 adapter 编码返回。不请求上游。
|
||||
|
||||
`GET /v1/models/{provider_id}/{model_name}` 从 URL 提取统一模型 ID,解析后查询数据库,组装为 `CanonicalModelInfo` 返回。不请求上游。
|
||||
|
||||
选择纯 DB 聚合而非实时查询上游的原因:
|
||||
1. 管理员通过 `/api/models` 控制哪些模型对用户可见,网关的意义在于控制可见性
|
||||
2. 响应速度快,不依赖上游可用性
|
||||
3. 符合当前架构中管理员手动配置 provider 和 model 的设计哲学
|
||||
|
||||
### D4: 跨协议响应 model 字段覆写
|
||||
|
||||
跨协议场景下,上游返回的响应经过 decode → encode 全量转换。上游响应中的 `model` 字段是原生模型名(如 `gpt-4`),需要在返回给客户端前覆写为统一模型 ID。
|
||||
|
||||
实现位置:`ConversionEngine.ConvertHttpResponse` 新增 `modelOverride string` 参数。在解码上游响应到 canonical 后、编码客户端响应前,将 `canonical.Model` 设为 `modelOverride`。流式场景同理,`CreateStreamConverter` 同样接收 `modelOverride` 参数。
|
||||
|
||||
此方案仅在跨协议转换路径使用。选择在 canonical 层面处理的原因:
|
||||
1. 跨协议必须全量 decode → encode,canonical 的 Model 字段天然可覆写
|
||||
2. 不侵入各协议 adapter 的实现
|
||||
3. 与 Smart Passthrough 互补——跨协议不可保真,canonical 覆写是自然的
|
||||
|
||||
### D5: ProtocolAdapter 接口扩展
|
||||
|
||||
在 `ProtocolAdapter` 接口新增四个方法,将所有协议相关的 model 字段知识归属到 adapter:
|
||||
|
||||
1. `ExtractUnifiedModelID(nativePath string) (string, error)` — 从路径中提取统一模型 ID
|
||||
2. `ExtractModelName(body []byte, ifaceType InterfaceType) (string, error)` — 从请求体中提取 model 值(所有流程复用,替代 handler 层硬编码的 `extractModelName`)
|
||||
3. `RewriteRequestModelName(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error)` — 最小化 JSON 改写请求体中的 model 字段(Smart Passthrough 请求方向)
|
||||
4. `RewriteResponseModelName(body []byte, newModel string, ifaceType InterfaceType) ([]byte, error)` — 最小化 JSON 改写响应体中的 model 字段(Smart Passthrough 响应方向)
|
||||
|
||||
拆分请求/响应方向的原因:请求体和响应体的 JSON 结构可能不同,model 字段的位置可能不同(当前 OpenAI/Anthropic 协议碰巧都在顶层 `"model"`,但未来协议不一定)。拆分后 adapter 各自独立实现,各自按 ifaceType 分派。
|
||||
|
||||
`ExtractModelName` 和两个 `Rewrite*` 方法均接收 `InterfaceType` 参数,因为不同接口类型的请求体/响应体结构可能不同,adapter 按 ifaceType 分派具体的定位和改写逻辑。
|
||||
|
||||
对于 `isModelInfoPath` 的调整:允许 suffix 中包含 `/`,因为统一模型 ID 格式为 `provider_id/model_name`。
|
||||
|
||||
将此方法放在适配器接口而非 handler 中通用实现的原因:不同协议的模型详情路径格式和请求体结构可能不同,各自拥有独立演进能力。
|
||||
|
||||
### D6: provider_id 字符集约束
|
||||
|
||||
创建供应商时校验 `id` 字段必须匹配 `^[a-zA-Z0-9_]+$`,长度 1-64。
|
||||
|
||||
选择严格限制而非仅排除 `/` 的原因:统一模型 ID 出现在 URL 路径和 JSON 中,`?`、`#`、`&`、`=` 等字符会在 URL 中引起解析问题。限制为字母数字下划线后,URL 中永远安全,不需要编码。
|
||||
|
||||
### D7: pkg/modelid 工具包
|
||||
|
||||
新增 `pkg/modelid` 包,提供:
|
||||
- `ParseUnifiedModelID(id string) (providerID, modelName string, error)` — 解析
|
||||
- `FormatUnifiedModelID(providerID, modelName string) string` — 格式化
|
||||
- `ValidateProviderID(id string) error` — 校验供应商 ID
|
||||
- `IsValidUnifiedModelID(id string) bool` — 校验统一模型 ID
|
||||
|
||||
使用标准库 `strings.SplitN` 和 `regexp` 实现,不引入新依赖。
|
||||
|
||||
### D8: 同协议 Smart Passthrough
|
||||
|
||||
当前同协议透传将请求体原样转发,跳过 decode → encode,保持参数完全保真。但统一模型 ID 要求改写 model 字段,原样透传无法满足。
|
||||
|
||||
**Smart Passthrough**:保留同协议透传的保真优势,通过 `json.RawMessage` 做最小化改写。
|
||||
|
||||
实现方式:adapter 的 `RewriteRequestModelName` 和 `RewriteResponseModelName` 方法各自解析 JSON 为 `map[string]json.RawMessage`,只替换 model 字段的 value,其余字段保留原始 bytes,不经过任何类型转换。参数保真、不丢精度、不改字段顺序。
|
||||
|
||||
各接口类型策略:
|
||||
- Chat/Embedding/Rerank(同协议):Smart Passthrough — 请求改写 model(统一 ID → 上游名),响应改写 model(上游名 → 统一 ID)
|
||||
- Chat/Embedding/Rerank(跨协议):全量 decode → encode + modelOverride
|
||||
- Models/ModelInfo:本地数据库聚合,不请求上游
|
||||
- Passthrough(未知路径):原样透传,不改写 model
|
||||
|
||||
选择让 adapter 拥有完整协议知识(而非通用 json hack)的原因:
|
||||
1. 不同协议的 model 字段位置可能不同,adapter 按 InterfaceType 分派
|
||||
2. 请求和响应的 model 字段位置可能不同,拆分 RewriteRequestModelName/RewriteResponseModelName 各自独立实现
|
||||
3. adapter 内部实现 `ExtractModelName` 和两个 `Rewrite*` 方法可共享同一份"model 在哪"的定位逻辑
|
||||
4. 所有流程复用 `ExtractModelName`,同协议额外复用 `RewriteRequestModelName` + `RewriteResponseModelName`
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[BREAKING CHANGE]** 代理接口 model 字段格式变更,现有客户端必须适配 → 统一 ID 格式简单直观,服务尚未上线无旧客户端
|
||||
- **[联合唯一约束]** 同一供应商下相同 model_name 不允许重复 → 这是正确的行为,语义上就不应该重复
|
||||
- **[model_name 含特殊字符]** 上游模型名可能含 `/`(如 Azure deployment 路径)→ 解析用 `SplitN("/", 2)` 安全,管理接口用 `id` 定位不受影响,代理接口中统一 ID 出现在 JSON body 和 URL 路径中均安全
|
||||
- **[流式响应覆写]** 同协议流式场景需逐 SSE chunk 调用 RewriteResponseModelName → 每个 chunk 多一次轻量 JSON 解析,用 json.RawMessage 保证开销极小
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。所有关键决策已在探索阶段确认。
|
||||
Reference in New Issue
Block a user