1
0
Files
nex/openspec/changes/unified-model-id/design.md

138 lines
9.2 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.
## 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
无。所有关键决策已在探索阶段确认。