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