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

9.2 KiB
Raw Blame History

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-4anthropic/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组装为 CanonicalModelListID 字段使用统一模型 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.SplitNregexp 实现,不引入新依赖。

D8: 同协议 Smart Passthrough

当前同协议透传将请求体原样转发,跳过 decode → encode保持参数完全保真。但统一模型 ID 要求改写 model 字段,原样透传无法满足。

Smart Passthrough:保留同协议透传的保真优势,通过 json.RawMessage 做最小化改写。

实现方式adapter 的 RewriteRequestModelNameRewriteResponseModelName 方法各自解析 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

无。所有关键决策已在探索阶段确认。