1
0

feat: 实现统一模型 ID 机制

实现统一模型 ID 格式 (provider_id/model_name),支持跨协议模型标识和 Smart Passthrough。

核心变更:
- 新增 pkg/modelid 包:解析、格式化、校验统一模型 ID
- 数据库迁移:models 表使用 UUID 主键 + UNIQUE(provider_id, model_name) 约束
- Repository 层:FindByProviderAndModelName、ListEnabled 方法
- Service 层:联合唯一校验、provider ID 字符集校验
- Conversion 层:ExtractModelName、RewriteRequestModelName/RewriteResponseModelName 方法
- Handler 层:统一模型 ID 路由、Smart Passthrough、Models API 本地聚合
- 新增 error-responses、unified-model-id 规范

测试覆盖:
- 单元测试:modelid、conversion、handler、service、repository
- 集成测试:统一模型 ID 路由、Smart Passthrough 保真性、跨协议转换
- 迁移测试:UUID 主键、UNIQUE 约束、级联删除

OpenSpec:
- 归档 unified-model-id 变更到 archive/2026-04-21-unified-model-id
- 同步 11 个 delta specs 到 main specs
- 新增 error-responses、unified-model-id 规范文件
This commit is contained in:
2026-04-21 18:14:10 +08:00
parent 7f0f831226
commit 395887667d
73 changed files with 3360 additions and 1374 deletions

View File

@@ -277,4 +277,51 @@ ErrorCode SHALL 包含INVALID_INPUT、MISSING_REQUIRED_FIELD、INCOMPATIBLE_F
- **WHEN** Adapter 调用 buildHeaders(provider)
- **THEN** SHALL 从 provider.api_key 提取认证信息
- **THEN** SHALL 从 provider.adapter_config 提取协议专属配置
- **THEN** SHALL 使用 provider.model_name 覆盖请求中的 model 字段
- **THEN** SHALL 使用 provider.model_name 覆盖请求中的 model 字段
### Requirement: 跨协议响应转换支持 model 覆写
ConversionEngine SHALL 在跨协议响应转换时支持 model 字段覆写。
#### Scenario: ConvertHttpResponse 接收 modelOverride 参数
- **WHEN** 调用 `ConvertHttpResponse` 时传入 `modelOverride` 参数(跨协议场景,非空字符串)
- **THEN** SHALL 在解码上游响应到 canonical 后,将 `Model` 字段设为 `modelOverride`
- **THEN** SHALL 使用覆写后的 canonical 编码为客户端协议格式
#### Scenario: modelOverride 为空
- **WHEN** 调用 `ConvertHttpResponse``modelOverride` 为空字符串
- **THEN** SHALL NOT 覆写 canonical 的 Model 字段,保持上游原始值
#### Scenario: Chat 响应 model 覆写
- **WHEN** 跨协议转换 Chat 类型响应且 `modelOverride` 非空
- **THEN** `CanonicalResponse.Model` SHALL 被设为 `modelOverride`
#### Scenario: Embedding 响应 model 覆写
- **WHEN** 跨协议转换 Embedding 类型响应且 `modelOverride` 非空
- **THEN** `CanonicalEmbeddingResponse.Model` SHALL 被设为 `modelOverride`
#### Scenario: Rerank 响应 model 覆写
- **WHEN** 跨协议转换 Rerank 类型响应且 `modelOverride` 非空
- **THEN** `CanonicalRerankResponse.Model` SHALL 被设为 `modelOverride`
### Requirement: 跨协议流式转换支持 model 覆写
ConversionEngine SHALL 在跨协议流式转换时支持 model 字段覆写。
#### Scenario: CreateStreamConverter 接收 modelOverride 参数
- **WHEN** 调用 `CreateStreamConverter` 时传入 `modelOverride` 参数(跨协议场景)
- **THEN** SHALL 在流式 canonical 事件中将 `Model` 字段设为 `modelOverride`
### Requirement: TargetProvider 字段语义
TargetProvider 的 ModelName 字段 SHALL 存储上游供应商的模型名称(即 `model_name` 字段值),语义保持不变。
#### Scenario: encoder 使用 TargetProvider.ModelName
- **WHEN** 协议适配器编码请求时
- **THEN** SHALL 使用 `TargetProvider.ModelName` 作为发给上游的 `model` 字段值(值为路由结果中的 model_name

View File

@@ -28,9 +28,10 @@
#### Scenario: 初始迁移文件
- **WHEN** 创建初始迁移
- **THEN** SHALL 创建 001_initial_schema.sql
- **THEN** SHALL 创建单个初始迁移文件(如 `20260421000001_initial_schema.sql`
- **THEN** SHALL 包含 providers、models、usage_stats 表的创建语句
- **THEN** SHALL 包含外键约束
- **THEN** SHALL 包含索引创建语句
#### Scenario: Up 迁移
@@ -42,25 +43,19 @@
#### Scenario: Down 迁移
- **WHEN** 执行 down 迁移
- **THEN** SHALL 删除所有表
- **THEN** SHALL 删除所有表和索引
- **THEN** SHALL 按正确顺序删除(避免外键约束错误)
### Requirement: 添加索引迁移
### Requirement: models 表 schema 变更
系统 SHALL 创建索引迁移
系统 SHALL 在初始迁移脚本中直接创建新的 models 表结构(服务未上线,无需考虑数据迁移,迁移脚本已合并为单个初始迁移文件)
#### Scenario: 索引迁移文件
#### Scenario: 初始迁移 models 表结构
- **WHEN** 创建索引迁移
- **THEN** SHALL 创建 002_add_indexes.sql
- **THEN** SHALL 为常用查询字段添加索引
#### Scenario: 索引定义
- **WHEN** 添加索引
- **THEN** SHALL 为 models(provider_id) 添加索引
- **THEN** SHALL 为 models(model_name) 添加索引
- **THEN** SHALL 为 usage_stats(provider_id, model_name, date) 添加复合索引
- **WHEN** 执行迁移
- **THEN** SHALL CREATE models 表包含字段idTEXT PRIMARY KEY存储 UUID 字符串、provider_idTEXT NOT NULL、model_nameTEXT NOT NULL、enabledINTEGER DEFAULT 1、created_atDATETIME
- **THEN** SHALL 存在 UNIQUE(provider_id, model_name) 约束
- **THEN** SHALL 存在 FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
### Requirement: 迁移命令集成

View File

@@ -0,0 +1,209 @@
# Error Responses
## Purpose
定义系统统一的错误响应格式和各类错误场景,确保客户端能够一致地处理错误。
## Requirements
### Requirement: 统一错误响应格式
系统 SHALL 使用统一的错误响应格式。
#### Scenario: 标准错误格式
- **WHEN** 返回错误响应
- **THEN** SHALL 使用以下 JSON 格式:
```json
{
"error": "错误描述",
"code": "ERROR_CODE"
}
```
- **THEN** `error` 字段 SHALL 包含人类可读的错误描述
- **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选)
### Requirement: provider_id 校验错误
系统 SHALL 对 provider_id 校验错误返回明确的错误信息。
#### Scenario: provider_id 包含非法字符
- **WHEN** 创建或更新供应商时provider_id 包含非 `[a-zA-Z0-9_]` 字符
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "供应商 ID 仅允许字母、数字、下划线",
"code": "INVALID_PROVIDER_ID"
}
```
#### Scenario: provider_id 长度超限
- **WHEN** 创建或更新供应商时provider_id 长度超过 64
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "供应商 ID 长度不能超过 64 个字符",
"code": "INVALID_PROVIDER_ID"
}
```
### Requirement: 联合唯一约束冲突错误
系统 SHALL 对联合唯一约束冲突返回明确的错误信息。
#### Scenario: 创建模型时 provider_id + model_name 组合已存在
- **WHEN** 创建模型时provider_id + model_name 组合已存在
- **THEN** SHALL 返回 HTTP 409 Conflict
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "同一供应商下模型名称已存在",
"code": "duplicate_model"
}
```
#### Scenario: 更新模型时导致 provider_id + model_name 组合冲突
- **WHEN** 更新模型时,修改 provider_id 或 model_name 导致与已有记录冲突
- **THEN** SHALL 返回 HTTP 409 Conflict
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "同一供应商下模型名称已存在",
"code": "duplicate_model"
}
```
### Requirement: 资源不存在错误
系统 SHALL 对资源不存在返回明确的错误信息。
#### Scenario: 模型不存在
- **WHEN** 查询或操作不存在的模型
- **THEN** SHALL 返回 HTTP 404 Not Found
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "模型未找到"
}
```
#### Scenario: 供应商不存在
- **WHEN** 创建模型时指定的供应商不存在
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "供应商不存在"
}
```
### Requirement: 统一模型 ID 格式错误
系统 SHALL 对统一模型 ID 格式错误返回明确的错误信息。
#### Scenario: 统一模型 ID 格式无效
- **WHEN** 代理请求中的 model 字段不是有效的统一模型 ID 格式
- **THEN** 请求 SHALL 走 forwardPassthrough 透传到上游(兼容未适配的客户端)
- **THEN** 不返回错误,保持与上游的兼容性
**设计理由:**
- 统一模型 ID 是 BREAKING CHANGE部分旧客户端可能仍使用原始模型名
- 透传策略允许上游自行判断并返回错误(如 404 model not found
- 网关作为透明代理,不应拦截所有格式非法的请求
#### Scenario: 统一模型 ID 格式有效但对应模型不存在
- **WHEN** 代理请求中的 model 字段是有效的统一模型 ID 格式(含 `/`),但数据库中找不到对应的模型
- **THEN** SHALL 返回 HTTP 404 Not Found
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "模型未找到"
}
```
#### Scenario: 统一模型 ID 对应的模型不存在
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的模型
- **THEN** SHALL 返回 HTTP 404 Not Found
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "模型未找到"
}
```
#### Scenario: 统一模型 ID 对应的模型已禁用
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
- **THEN** SHALL 返回 HTTP 404 Not Found
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "模型未找到"
}
```
#### Scenario: 统一模型 ID 对应的供应商已禁用
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
- **THEN** SHALL 返回 HTTP 404 Not Found
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "模型未找到"
}
```
### Requirement: JSON 格式错误
系统 SHALL 对请求体 JSON 格式错误返回明确的错误信息。
#### Scenario: 请求体 JSON 格式错误
- **WHEN** 代理请求的请求体不是有效的 JSON 格式
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "请求体 JSON 格式错误",
"code": "INVALID_JSON"
}
```
#### Scenario: Smart Passthrough 时请求体 JSON 格式错误
- **WHEN** 同协议 Smart Passthrough 场景下,请求体 JSON 格式不正确
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "请求体 JSON 格式错误",
"code": "INVALID_JSON"
}
```
### Requirement: 不可变字段错误
系统 SHALL 对尝试修改不可变字段返回明确的错误信息。
#### Scenario: 尝试修改供应商 ID
- **WHEN** 更新供应商时,请求体中包含 `id` 字段
- **THEN** SHALL 返回 HTTP 400 Bad Request
- **THEN** SHALL 返回以下 JSON 格式:
```json
{
"error": "供应商 ID 不允许修改",
"code": "IMMUTABLE_FIELD"
}
```

View File

@@ -10,10 +10,12 @@
#### Scenario: 使用有效数据创建模型
- **WHEN** 向 `/api/models` 发送 POST 请求,携带有效的模型数据(id, provider_id, model_name
- **WHEN** 向 `/api/models` 发送 POST 请求携带有效的模型数据provider_id, model_name,不提供 id 字段
- **THEN** 网关 SHALL 自动生成 UUID 作为模型 id
- **THEN** 网关 SHALL 在数据库中创建新的模型记录
- **THEN** 网关 SHALL 返回创建的模型,状态码为 201
- **THEN** 模型 SHALL 默认启用
- **THEN** 返回的模型 SHALL 包含 `unified_id` 字段,值为 `{provider_id}/{model_name}`
#### Scenario: 使用不存在的供应商创建模型
@@ -21,7 +23,11 @@
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
- **THEN** 错误 SHALL 指示供应商不存在
**变更说明:** handler 通过 ModelService 调用,数据访问通过 ModelRepository 和 ProviderRepository。API 接口保持不变。
#### Scenario: 创建重复模型
- **WHEN** 向 `/api/models` 发送 POST 请求,携带已存在的 provider_id + model_name 组合
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
- **THEN** 错误 SHALL 指示同一供应商下模型名称已存在
### Requirement: 列出所有模型
@@ -31,9 +37,7 @@
- **WHEN** 向 `/api/models` 发送 GET 请求
- **THEN** 网关 SHALL 返回所有模型的列表
- **THEN** 每个模型 SHALL 包含 id, provider_id, model_name, enabled, created_at
**变更说明:** 数据访问从 config 包迁移到 ModelRepository。API 接口保持不变。
- **THEN** 每个模型 SHALL 包含 id, provider_id, model_name, unified_id, enabled, created_at
### Requirement: 按供应商列出模型
@@ -43,8 +47,7 @@
- **WHEN** 向 `/api/models?provider_id=<provider_id>` 发送 GET 请求
- **THEN** 网关 SHALL 返回指定供应商的模型列表
**变更说明:** 通过 ModelService 和 ModelRepository 实现。API 接口保持不变。
- **THEN** 每个模型 SHALL 包含 unified_id 字段
### Requirement: 更新模型配置
@@ -55,14 +58,12 @@
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,携带有效的模型数据
- **THEN** 网关 SHALL 更新数据库中的模型记录
- **THEN** 网关 SHALL 返回更新后的模型
- **THEN** 返回的模型 SHALL 包含更新后的 unified_id
#### Scenario: 更新模型供应商
#### Scenario: 更新模型为重复组合
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,携带新的 provider_id
- **THEN** 网关 SHALL 验证新供应商是否存在
- **THEN** 网关 SHALL 更新模型的供应商关联
**变更说明:** 通过 ModelService、ModelRepository 和 ProviderRepository 实现。API 接口保持不变。
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,更新 provider_id 或 model_name 导致与已有记录重复
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
### Requirement: 删除模型配置
@@ -74,8 +75,6 @@
- **THEN** 网关 SHALL 删除模型记录
- **THEN** 网关 SHALL 返回状态码 204 (No Content)
**变更说明:** 通过 ModelService 和 ModelRepository 实现。API 接口保持不变。
### Requirement: 使用 service 层处理业务逻辑
Handler SHALL 通过 ModelService 处理业务逻辑。
@@ -85,25 +84,60 @@ Handler SHALL 通过 ModelService 处理业务逻辑。
- **WHEN** handler 收到请求
- **THEN** SHALL 调用对应的 ModelService 方法Create、Get、List、Update、Delete
- **THEN** SHALL 使用 domain.Model 类型
- **THEN** Create 时 SHALL 调用 `uuid.New()` 生成 id
#### Scenario: 供应商验证
#### Scenario: 供应商验证和唯一性校验
- **WHEN** 创建或更新模型
- **THEN** SHALL 在 service 层验证供应商存在
- **THEN** SHALL 通过 ProviderRepository 查询供应商
- **THEN** SHALL 在 service 层验证 provider_id + model_name 联合唯一
### Requirement: 联合唯一约束并发处理
创建或更新模型时SHALL 使用应用层校验 + 数据库约束双重保险处理联合唯一约束。
#### Scenario: 应用层快速失败
- **WHEN** 创建或更新模型前
- **THEN** SHALL 先检查 provider_id + model_name 是否已存在
- **THEN** 如已存在SHALL 返回 HTTP 409 Conflict
- **THEN** SHALL 返回错误格式:
```json
{
"error": "同一供应商下模型名称已存在",
"code": "duplicate_model"
}
```
#### Scenario: 数据库约束兜底
- **WHEN** 并发创建导致应用层校验通过但数据库写入失败
- **THEN** SHALL 捕获数据库 UNIQUE 约束错误
- **THEN** SHALL 转换为 HTTP 409 Conflict 错误返回
- **THEN** SHALL 返回错误格式:
```json
{
"error": "同一供应商下模型名称已存在",
"code": "duplicate_model"
}
```
#### Scenario: SQLite UNIQUE 约束错误检测
- **WHEN** 捕获数据库错误
- **THEN** SHALL 检查错误信息是否包含 "UNIQUE constraint failed"
- **THEN** 如匹配SHALL 识别为联合唯一约束冲突
### Requirement: 使用 repository 层访问数据
Service SHALL 通过 ModelRepository 访问数据。
#### Scenario: 调用 repository 方法
#### Scenario: 联合查询
- **WHEN** service 处理业务逻辑
- **THEN** SHALL 调用对应的 ModelRepository 方法
- **THEN** SHALL 使用 domain.Model 类型
- **WHEN** service 需要按 provider 和 model_name 查询模型
- **THEN** SHALL 调用 `FindByProviderAndModelName(providerID, modelName)` 方法
#### Scenario: 数据验证
#### Scenario: 查询所有启用模型
- **WHEN** 创建或更新模型
- **THEN** SHALL 在 service 层验证业务规则
- **THEN** SHALL 在 repository 层执行数据库操作
- **WHEN** proxy handler 需要聚合模型列表
- **THEN** SHALL 调用 `ListEnabled()` 方法,返回所有 enabled 的模型(关联 enabled 的供应商)

View File

@@ -270,4 +270,73 @@ Decoder 几乎 1:1 映射,维护最小状态机:
- **WHEN** interfaceType 为 EMBEDDINGS 或 RERANK
- **THEN** supportsInterface SHALL 返回 false
- **THEN** 引擎 SHALL 走透传或返回空响应
- **THEN** 引擎 SHALL 走透传或返回空响应
### Requirement: 模型详情路径识别
Anthropic 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径。
#### Scenario: 含斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/v1/models/anthropic/claude-3-opus`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### Scenario: 含多段斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/v1/models/azure/deployments/gpt-4`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### Scenario: 模型列表路径不受影响
- **WHEN** 路径为 `/v1/models`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
### Requirement: 提取统一模型 ID
Anthropic 适配器 SHALL 从路径中提取统一模型 ID。
#### Scenario: 标准路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/anthropic/claude-3-opus")`
- **THEN** SHALL 返回 `"anthropic/claude-3-opus"`
#### Scenario: 复杂路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")`
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
#### Scenario: 非模型详情路径
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models")`
- **THEN** SHALL 返回错误
### Requirement: 从请求体提取 model
Anthropic 适配器 SHALL 按 InterfaceType 从请求体中提取 model 值。
#### Scenario: Chat 请求提取 model
- **WHEN** 调用 `ExtractModelName(body, InterfaceTypeChat)`body 为 `{"model":"anthropic/claude-3-opus","messages":[...]}`
- **THEN** SHALL 返回 `"anthropic/claude-3-opus"`
#### Scenario: 无 model 字段
- **WHEN** 调用 `ExtractModelName(body, ifaceType)`body 中不含 model 字段
- **THEN** SHALL 返回错误
### Requirement: 最小化改写请求体 model 字段
Anthropic 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改写请求体中的 model 字段,其余字段保持原始 bytes。
#### Scenario: 请求体 model 改写(统一 ID → 上游名)
- **WHEN** 调用 `RewriteRequestModelName(body, "claude-3-opus-20240229", InterfaceTypeChat)`
- **THEN** SHALL 将请求体中 model 字段替换为 `"claude-3-opus-20240229"`,其余字段原样保留
### Requirement: 最小化改写响应体 model 字段
Anthropic 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改写响应体中的 model 字段,其余字段保持原始 bytes。请求体和响应体的 model 字段位置可能不同,各自独立实现。
#### Scenario: 响应体 model 改写(上游名 → 统一 ID
- **WHEN** 调用 `RewriteResponseModelName(body, "anthropic/claude-3-opus", InterfaceTypeChat)`
- **THEN** SHALL 将响应体中 model 字段替换为 `"anthropic/claude-3-opus"`,其余字段原样保留

View File

@@ -269,4 +269,91 @@ Encoder SHALL 维护状态:
#### Scenario: /rerank 接口
- **WHEN** 解码/编码 rerank 请求和响应
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射
### Requirement: 模型详情路径识别
OpenAI 适配器 SHALL 正确识别包含统一模型 ID 的模型详情路径。
#### Scenario: 含斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/v1/models/openai/gpt-4`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### Scenario: 含多段斜杠的统一模型 ID 路径
- **WHEN** 路径为 `/v1/models/azure/accounts/org-123/models/gpt-4`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModelInfo`
#### Scenario: 模型列表路径不受影响
- **WHEN** 路径为 `/v1/models`
- **THEN** `DetectInterfaceType` SHALL 返回 `InterfaceTypeModels`
### Requirement: 提取统一模型 ID
OpenAI 适配器 SHALL 从路径中提取统一模型 ID。
#### Scenario: 标准路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/openai/gpt-4")`
- **THEN** SHALL 返回 `"openai/gpt-4"`
#### Scenario: 复杂路径提取
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models/azure/accounts/org/models/gpt-4")`
- **THEN** SHALL 返回 `"azure/accounts/org/models/gpt-4"`
#### Scenario: 非模型详情路径
- **WHEN** 调用 `ExtractUnifiedModelID("/v1/models")`
- **THEN** SHALL 返回错误
### Requirement: 从请求体提取 model
OpenAI 适配器 SHALL 按 InterfaceType 从请求体中提取 model 值。
#### Scenario: Chat 请求提取 model
- **WHEN** 调用 `ExtractModelName(body, InterfaceTypeChat)`body 为 `{"model":"openai/gpt-4","messages":[...]}`
- **THEN** SHALL 返回 `"openai/gpt-4"`
#### Scenario: Embedding 请求提取 model
- **WHEN** 调用 `ExtractModelName(body, InterfaceTypeEmbeddings)`body 为 `{"model":"openai/text-embedding-3","input":"text"}`
- **THEN** SHALL 返回 `"openai/text-embedding-3"`
#### Scenario: 无 model 字段
- **WHEN** 调用 `ExtractModelName(body, ifaceType)`body 中不含 model 字段
- **THEN** SHALL 返回错误
### Requirement: 最小化改写请求体 model 字段
OpenAI 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改写请求体中的 model 字段,其余字段保持原始 bytes。
#### Scenario: 请求体 model 改写(统一 ID → 上游名)
- **WHEN** 调用 `RewriteRequestModelName(body, "gpt-4", InterfaceTypeChat)`body 为 `{"model":"openai/gpt-4","messages":[...],"some_param":"value"}`
- **THEN** SHALL 返回 `{"model":"gpt-4","messages":[...],"some_param":"value"}`
- **THEN** 除 model 外的字段 SHALL 保持原始 bytes 不变
#### Scenario: 不同 InterfaceType 的请求改写
- **WHEN** 调用 `RewriteRequestModelName(body, "gpt-4", InterfaceTypeEmbeddings)`
- **THEN** SHALL 按 Embedding 接口的请求体 model 字段位置进行改写
- **WHEN** 调用 `RewriteRequestModelName(body, "gpt-4", InterfaceTypeRerank)`
- **THEN** SHALL 按 Rerank 接口的请求体 model 字段位置进行改写
### Requirement: 最小化改写响应体 model 字段
OpenAI 适配器 SHALL 按 InterfaceType 用 `json.RawMessage` 最小化改写响应体中的 model 字段,其余字段保持原始 bytes。请求体和响应体的 model 字段位置可能不同,各自独立实现。
#### Scenario: 响应体 model 改写(上游名 → 统一 ID
- **WHEN** 调用 `RewriteResponseModelName(body, "openai/gpt-4", InterfaceTypeChat)`body 为上游 Chat 响应
- **THEN** SHALL 将 model 字段替换为 `"openai/gpt-4"`,其余字段原样保留
#### Scenario: 不同 InterfaceType 的响应改写
- **WHEN** 调用 `RewriteResponseModelName(body, "openai/gpt-4", InterfaceTypeEmbeddings)`
- **THEN** SHALL 按 Embedding 接口的响应体 model 字段位置进行改写

View File

@@ -29,7 +29,44 @@
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
- **THEN** 错误 SHALL 指示缺少哪些字段
**变更说明:** handler 通过 ProviderService 调用,数据访问通过 ProviderRepository。API 接口保持不变。
#### Scenario: 创建供应商时 ID 包含非法字符
- **WHEN** 向 `/api/providers` 发送 POST 请求id 包含非 `[a-zA-Z0-9_]` 字符
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
- **THEN** 错误 SHALL 指示 id 仅允许字母、数字、下划线
#### Scenario: 创建供应商时 ID 过长
- **WHEN** 向 `/api/providers` 发送 POST 请求id 长度超过 64
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
### Requirement: 供应商 ID 不允许修改
供应商 ID 是主键,用于构建统一模型 ID不允许修改。
#### Scenario: 尝试修改供应商 ID
- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,请求体中包含 `id` 字段
- **THEN** ProviderService.Update SHALL 在 service 层校验并返回错误
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
- **THEN** 错误 SHALL 指示供应商 ID 不允许修改
- **THEN** 错误格式 SHALL 为:
```json
{
"error": "供应商 ID 不允许修改",
"code": "IMMUTABLE_FIELD"
}
```
**校验位置:** Service 层(`ProviderService.Update`
- Service 层校验保证所有调用方handler、CLI、未来 API统一遵守
- Handler 层负责捕获 `ErrImmutableField` 并转换为 HTTP 400 响应
**原因:**
- 供应商 ID 是主键,用于构建统一模型 IDprovider_id/model_name
- 修改 ID 会导致所有统一模型 ID 失效
- 客户端缓存的模型 ID 全部失效
- 如需修改,应创建新供应商并迁移模型
### Requirement: 列出所有供应商

View File

@@ -92,6 +92,44 @@
- **THEN** SHALL 验证至少提供一个可更新字段
- **THEN** SHALL 验证字段值有效性
### Requirement: 供应商 ID 校验
创建供应商时SHALL 对 `id` 字段进行字符集校验。
#### Scenario: 合法字符集
- **WHEN** 创建供应商id 仅包含 `[a-zA-Z0-9_]` 字符
- **THEN** SHALL 校验通过
#### Scenario: 非法字符
- **WHEN** 创建供应商id 包含 `-``.``/`、空格、中文等非 `[a-zA-Z0-9_]` 字符
- **THEN** SHALL 返回 400 错误
#### Scenario: 长度限制
- **WHEN** 创建供应商id 长度超过 64
- **THEN** SHALL 返回 400 错误
### Requirement: 模型创建校验
创建模型时SHALL 对 `provider_id` + `model_name` 进行联合唯一性校验。
#### Scenario: 正常创建
- **WHEN** 创建模型provider_id 存在且 provider_id + model_name 组合唯一
- **THEN** SHALL 校验通过
#### Scenario: 联合唯一冲突
- **WHEN** 创建模型provider_id + model_name 组合已存在
- **THEN** SHALL 返回 409 错误
#### Scenario: model_name 为空
- **WHEN** 创建模型,未提供 model_name
- **THEN** SHALL 返回 400 错误
### Requirement: 返回友好的验证错误
系统 SHALL 返回友好的验证错误响应。

View File

@@ -0,0 +1,85 @@
# Unified Model ID
## Purpose
定义统一模型 ID 的格式、解析、格式化和校验规则,确保跨协议的模型标识一致性。
## Requirements
### Requirement: 解析统一模型 ID
系统 SHALL 提供 `ParseUnifiedModelID` 函数,将 `provider_id/model_name` 格式的字符串解析为独立的 providerID 和 modelName。
#### Scenario: 标准格式解析
- **WHEN** 传入 `"openai/gpt-4"`
- **THEN** SHALL 返回 providerID=`"openai"`, modelName=`"gpt-4"`
#### Scenario: model_name 含斜杠的解析
- **WHEN** 传入 `"azure/accounts/org-123/models/gpt-4"`
- **THEN** SHALL 在第一个 `/` 处分割,返回 providerID=`"azure"`, modelName=`"accounts/org-123/models/gpt-4"`
#### Scenario: 缺少分隔符
- **WHEN** 传入不含 `/` 的字符串(如 `"gpt-4"`
- **THEN** SHALL 返回错误
#### Scenario: 空字符串
- **WHEN** 传入空字符串
- **THEN** SHALL 返回错误
#### Scenario: 只有分隔符
- **WHEN** 传入 `"/model"``"provider/"``"/"`
- **THEN** SHALL 返回错误
#### Scenario: providerID 不符合字符集
- **WHEN** 传入 `"open-ai/gpt-4"``"open.ai/gpt-4"``"供应商/gpt-4"`providerID 含非 `[a-zA-Z0-9_]` 字符)
- **THEN** SHALL 返回错误
### Requirement: 格式化统一模型 ID
系统 SHALL 提供 `FormatUnifiedModelID` 函数,将 providerID 和 modelName 组合格式化为统一模型 ID。
#### Scenario: 格式化
- **WHEN** 传入 providerID=`"openai"`, modelName=`"gpt-4"`
- **THEN** SHALL 返回 `"openai/gpt-4"`
### Requirement: 校验供应商 ID
系统 SHALL 提供 `ValidateProviderID` 函数,校验 providerID 仅包含字母、数字、下划线。
#### Scenario: 合法 ID
- **WHEN** 传入 `"openai"`, `"deep_seek"`, `"provider01"`, `"OpenAI"`
- **THEN** SHALL 校验通过
#### Scenario: 含非法字符
- **WHEN** 传入含 `-``.``/`、空格、中文等非 `[a-zA-Z0-9_]` 字符的 ID
- **THEN** SHALL 返回错误
#### Scenario: 空字符串或过长
- **WHEN** 传入空字符串
- **THEN** SHALL 返回错误
- **WHEN** 传入超过 64 个字符的 ID
- **THEN** SHALL 返回错误
### Requirement: 校验统一模型 ID
系统 SHALL 提供 `IsValidUnifiedModelID` 函数,判断字符串是否为合法的统一模型 ID 格式。
#### Scenario: 合法 ID
- **WHEN** 传入 `"openai/gpt-4"`
- **THEN** SHALL 返回 `true`
#### Scenario: 非法 ID
- **WHEN** 传入不含 `/` 的字符串、空字符串、providerID 不符合 `[a-zA-Z0-9_]+` 的字符串
- **THEN** SHALL 返回 `false`

View File

@@ -105,4 +105,125 @@ ProxyHandler SHALL 支持 GET 请求的扩展层接口代理。
- **THEN** SHALL 调用 engine.convertHttpRequestGET 请求 body 为空)
- **THEN** SHALL 调用 providerClient.Send 发送请求
- **THEN** SHALL 调用 engine.convertHttpResponse 转换响应格式
- **THEN** SHALL 返回转换后的响应
- **THEN** SHALL 返回转换后的响应
### Requirement: 代理请求路由
ProxyHandler SHALL 使用统一模型 ID 路由所有代理请求。
#### Scenario: 提取统一模型 ID
- **WHEN** 收到 Chat、Embeddings 或 Rerank 接口的 POST 请求(含请求体)
- **THEN** SHALL 调用客户端协议 adapter 的 `ExtractModelName(body, ifaceType)` 提取 model 值
- **THEN** SHALL 调用 `ParseUnifiedModelID` 解析得到 providerID 和 modelName
- **THEN** SHALL 调用 `RoutingService.RouteByModelName(providerID, modelName)` 路由
#### Scenario: GET 请求或无请求体
- **WHEN** 收到 GET 请求或请求体为空或请求体中无法提取 model 字段
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容未适配的客户端和无 body 请求)
#### Scenario: 无效的统一模型 ID
- **WHEN** 请求体中 `model` 字段不是有效的统一模型 ID 格式(不含 `/`
- **THEN** SHALL 走 forwardPassthrough 透传到上游供应商(兼容使用原始模型名的客户端)
#### Scenario: 模型不存在
- **WHEN** 解析统一模型 ID 后,数据库中找不到对应的 provider_id + model_name 组合
- **THEN** SHALL 返回错误响应,状态码为 404
#### Scenario: 模型已禁用
- **WHEN** 解析统一模型 ID 后,对应的模型 enabled 为 false
- **THEN** SHALL 返回错误响应,状态码为 404
#### Scenario: 供应商已禁用
- **WHEN** 解析统一模型 ID 后,对应的供应商 enabled 为 false
- **THEN** SHALL 返回错误响应,状态码为 404
### Requirement: 同协议 Smart Passthrough
当客户端协议与供应商协议相同时ProxyHandler SHALL 使用 Smart Passthrough 处理 Chat、Embedding、Rerank 请求。
#### Scenario: 同协议非流式请求
- **WHEN** 客户端协议 == 供应商协议,且为非流式请求
- **THEN** SHALL 调用 adapter 的 `RewriteRequestModelName(body, modelName, ifaceType)` 将请求体中 model 从统一 ID 改写为上游模型名
- **THEN** SHALL 构建 URL 和 Headers同当前透传逻辑
- **THEN** SHALL 发送改写后的请求体到上游
- **THEN** SHALL 调用 adapter 的 `RewriteResponseModelName(resp.Body, unifiedModelID, ifaceType)` 将响应中 model 从上游名改写为统一 ID
- **THEN** SHALL NOT 对 body 做全量 decode → encode保持未改写字段的原始 bytes
#### Scenario: 同协议流式请求
- **WHEN** 客户端协议 == 供应商协议,且为流式请求
- **THEN** SHALL 对请求体做 `RewriteRequestModelName` 改写 model 字段
- **THEN** SHALL 逐 SSE chunk 调用 `RewriteResponseModelName` 改写响应中 model 字段
- **THEN** SHALL NOT 对 chunk 做全量 decode → encode
#### Scenario: Smart Passthrough 保真性
- **WHEN** 客户端发送含未知参数的请求(如 `{"model":"openai/gpt-4","some_new_param":"value"}`
- **THEN** 上游 SHALL 收到 `{"model":"gpt-4","some_new_param":"value"}`
- **THEN** `some_new_param` SHALL 保持原始值不变,不丢失、不改变类型
### Requirement: 跨协议完整转换
当客户端协议与供应商协议不同时ProxyHandler SHALL 使用全量转换路径。
#### Scenario: 跨协议非流式请求
- **WHEN** 客户端协议 != 供应商协议
- **THEN** SHALL 走 `ConvertHttpRequest` 全量转换encoder 中 provider.ModelName 覆盖 model
- **THEN** SHALL 走 `ConvertHttpResponse` 全量转换modelOverride 参数覆写 canonical.Model
#### Scenario: 跨协议流式请求
- **WHEN** 客户端协议 != 供应商协议,且为流式请求
- **THEN** SHALL 走 `CreateStreamConverter` 全量转换modelOverride 参数覆写流式 canonical 事件中的 Model
### Requirement: 模型列表本地聚合
ProxyHandler SHALL 从数据库聚合返回模型列表,不再透传上游。
#### Scenario: GET /v1/models
- **WHEN** 收到 `GET /{protocol}/v1/models` 请求
- **THEN** SHALL 从数据库查询所有 enabled 的模型(关联 enabled 的供应商)
- **THEN** SHALL 组装 `CanonicalModelList`,每个模型的 ID 字段为统一模型 ID`provider_id/model_name`Name 字段为 model_nameOwnedBy 字段为 provider_id
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
- **THEN** SHALL NOT 请求上游供应商
#### Scenario: 无可用模型
- **WHEN** 数据库中没有 enabled 的模型
- **THEN** SHALL 返回空列表
### Requirement: 模型详情本地查询
ProxyHandler SHALL 从数据库查询返回模型详情,不再透传上游。
#### Scenario: GET /v1/models/{unified_id}
- **WHEN** 收到 `GET /{protocol}/v1/models/{provider_id}/{model_name}` 请求
- **THEN** SHALL 调用 adapter 的 `ExtractUnifiedModelID` 提取统一模型 ID
- **THEN** SHALL 解析统一模型 ID 得到 providerID 和 modelName
- **THEN** SHALL 从数据库查询对应的模型和供应商
- **THEN** SHALL 组装 `CanonicalModelInfo`ID 字段为统一模型 ID`provider_id/model_name`Name 字段为 model_nameOwnedBy 字段为 provider_id
- **THEN** SHALL 使用客户端协议的 adapter 编码响应
- **THEN** SHALL NOT 请求上游供应商
#### Scenario: 模型详情不存在
- **WHEN** 统一模型 ID 对应的模型不存在或已禁用
- **THEN** SHALL 返回错误响应,状态码为 404
### Requirement: 统计记录
ProxyHandler SHALL 使用 providerID 和 modelName 记录使用统计。
#### Scenario: 异步记录统计
- **WHEN** 代理请求成功完成
- **THEN** SHALL 异步调用 `StatsService.Record(providerID, modelName)`

View File

@@ -22,7 +22,20 @@
- **THEN** 网关 SHALL 增加该供应商和模型的请求计数
- **THEN** 网关 SHALL 在流结束后记录统计
**变更说明:** 统计记录通过 StatsService 调用,数据访问通过 StatsRepository。API 接口保持不变。
### Requirement: 使用统计记录统一模型标识
系统 SHALL 使用 providerID 和 modelName上游模型名记录使用统计。
#### Scenario: 代理请求统计记录
- **WHEN** 代理请求成功完成
- **THEN** SHALL 记录 provider_id 和 model_name 到 usage_stats 表(参数来自路由结果)
- **THEN** SHALL 异步执行,不阻塞响应
#### Scenario: 查询统计
- **WHEN** 查询统计数据
- **THEN** 支持按 provider_id 和 model_name 过滤
### Requirement: 按供应商查询统计