1
0

docs: 添加 openspec 变更记录,归档已完成变更并添加 unified-model-id 提案

This commit is contained in:
2026-04-21 00:45:39 +08:00
parent 56ecc73d1b
commit f3a207fa16
14 changed files with 852 additions and 0 deletions

View 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 → encodecanonical 的 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
无。所有关键决策已在探索阶段确认。