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:
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-20
|
||||
@@ -1,137 +0,0 @@
|
||||
## 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
|
||||
|
||||
无。所有关键决策已在探索阶段确认。
|
||||
@@ -1,41 +0,0 @@
|
||||
## Why
|
||||
|
||||
当前网关直接透传上游供应商的原始模型名称(如 `gpt-4`、`claude-3-opus`),无法在多供应商场景下唯一标识一个模型。不同供应商可能存在同名模型,客户端无法区分应路由到哪个供应商。网关作为屏蔽供应商差异的统一入口,需要定义自有的模型标识体系,让客户端通过统一的 model ID 访问任意供应商的模型,同时拦截 `/v1/models` 等模型查询接口,聚合所有供应商的模型信息返回。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: 引入统一模型 ID 格式 `provider_id/model_name`(如 `openai/gpt-4`),所有代理接口(Chat、Embeddings、Rerank)的 `model` 字段必须使用此格式
|
||||
- **BREAKING**: `models` 表主键 `id` 改为 UUID 自动生成(不再由用户提供),`model_name` 字段语义保持不变(存储上游供应商模型名称),新增 `UNIQUE(provider_id, model_name)` 联合唯一约束
|
||||
- **BREAKING**: `provider_id` 限制为 `[a-zA-Z0-9_]+` 字符集,禁止特殊字符
|
||||
- `GET /v1/models` 改为从数据库聚合返回所有已启用模型,不再透传到上游供应商
|
||||
- `GET /v1/models/{unified_id}` 改为从数据库查询返回模型详情,不再透传到上游供应商
|
||||
- 同协议透传改为 Smart Passthrough:通过 `json.RawMessage` 最小化改写 model 字段,保持其余参数完全保真
|
||||
- 跨协议转换路径:通过 canonical 层面 modelOverride 参数覆写响应 model 字段
|
||||
- 管理 API (`/api/models`) 请求体字段适配,响应中新增 `unified_id` 字段
|
||||
- 新增 `pkg/modelid` 工具包,提供统一模型 ID 的解析、格式化、校验
|
||||
- ProtocolAdapter 接口新增 `ExtractUnifiedModelID`、`ExtractModelName`、`RewriteRequestModelName`、`RewriteResponseModelName` 方法,协议无关地处理 model 字段
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `unified-model-id`: 统一模型 ID 的解析、格式化、校验工具包,以及 `provider_id` 字符集约束
|
||||
|
||||
### Modified Capabilities
|
||||
- `model-management`: 模型表结构调整(id 改 UUID 自动生成、新增联合唯一约束),CRUD 接口字段变更(创建不再提供 id)
|
||||
- `provider-management`: provider_id 创建时增加字符集校验(`[a-zA-Z0-9_]+`)
|
||||
- `unified-proxy-handler`: 统一模型 ID 解析路由、Models/ModelInfo 接口改为本地聚合、同协议 Smart Passthrough、跨协议 modelOverride 覆写
|
||||
- `conversion-engine`: 跨协议场景下 ConvertHttpResponse 支持 model 覆写参数
|
||||
- `protocol-adapter-openai`: isModelInfoPath 适配含 `/` 路径、新增 ExtractUnifiedModelID、ExtractModelName、RewriteRequestModelName、RewriteResponseModelName
|
||||
- `protocol-adapter-anthropic`: isModelInfoPath 适配含 `/` 路径、新增 ExtractUnifiedModelID、ExtractModelName、RewriteRequestModelName、RewriteResponseModelName
|
||||
- `request-validation`: provider_id 字符集校验规则、模型创建校验适配
|
||||
- `database-migration`: models 表 schema 变更迁移(DROP + CREATE 重建)
|
||||
- `usage-statistics`: 明确统计记录使用 providerID + modelName 的上游模型名
|
||||
|
||||
## Impact
|
||||
|
||||
- **数据库**: models 表 schema 变更(DROP + CREATE 重建)
|
||||
- **API 兼容性**: 代理接口 model 字段格式为 BREAKING CHANGE,需客户端适配
|
||||
- **管理 API**: `/api/models` 请求体变更(创建不再提供 id,自动生成 UUID),响应新增 unified_id 字段
|
||||
- **代码模块**: domain、repository、service、handler、conversion、adapter 层均有改动
|
||||
- **测试**: routing service、proxy handler、adapter、model handler 需要新增/更新测试
|
||||
- **前端**: 本次变更不涉及前端适配,前端后续统一适配
|
||||
@@ -1,49 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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)
|
||||
@@ -1,13 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: models 表 schema 变更
|
||||
|
||||
系统 SHALL 通过迁移脚本重建 models 表结构(服务未上线,无需考虑数据迁移)。
|
||||
|
||||
#### Scenario: 迁移后 models 表结构
|
||||
|
||||
- **WHEN** 执行迁移
|
||||
- **THEN** SHALL 先 DROP 已有的 models 表(无旧数据)
|
||||
- **THEN** SHALL CREATE 新的 models 表,包含字段:id(TEXT PRIMARY KEY)、provider_id(TEXT NOT NULL)、model_name(TEXT NOT NULL)、enabled(INTEGER DEFAULT 1)、created_at(DATETIME)
|
||||
- **THEN** SHALL 存在 UNIQUE(provider_id, model_name) 约束
|
||||
- **THEN** SHALL 存在 FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
|
||||
@@ -1,105 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 创建模型配置
|
||||
|
||||
网关 SHALL 允许为供应商创建新的模型配置。
|
||||
|
||||
#### Scenario: 使用有效数据创建模型
|
||||
|
||||
- **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: 使用不存在的供应商创建模型
|
||||
|
||||
- **WHEN** 向 `/api/models` 发送 POST 请求,携带不存在的 provider_id
|
||||
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
|
||||
- **THEN** 错误 SHALL 指示供应商不存在
|
||||
|
||||
#### Scenario: 创建重复模型
|
||||
|
||||
- **WHEN** 向 `/api/models` 发送 POST 请求,携带已存在的 provider_id + model_name 组合
|
||||
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
|
||||
- **THEN** 错误 SHALL 指示该供应商下已存在相同模型
|
||||
|
||||
### Requirement: 列出所有模型
|
||||
|
||||
网关 SHALL 允许获取所有模型配置。
|
||||
|
||||
#### Scenario: 成功列出模型
|
||||
|
||||
- **WHEN** 向 `/api/models` 发送 GET 请求
|
||||
- **THEN** 网关 SHALL 返回所有模型的列表
|
||||
- **THEN** 每个模型 SHALL 包含 id, provider_id, model_name, unified_id, enabled, created_at
|
||||
|
||||
**变更说明:** 响应新增 unified_id 字段,移除旧语义的 id 自定义输入。
|
||||
|
||||
### Requirement: 按供应商列出模型
|
||||
|
||||
网关 SHALL 允许获取特定供应商的模型。
|
||||
|
||||
#### Scenario: 列出存在供应商的模型
|
||||
|
||||
- **WHEN** 向 `/api/models?provider_id=<provider_id>` 发送 GET 请求
|
||||
- **THEN** 网关 SHALL 返回指定供应商的模型列表
|
||||
- **THEN** 每个模型 SHALL 包含 unified_id 字段
|
||||
|
||||
### Requirement: 更新模型配置
|
||||
|
||||
网关 SHALL 允许更新现有模型配置。
|
||||
|
||||
#### Scenario: 使用有效数据更新模型
|
||||
|
||||
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,携带有效的模型数据
|
||||
- **THEN** 网关 SHALL 更新数据库中的模型记录
|
||||
- **THEN** 网关 SHALL 返回更新后的模型
|
||||
- **THEN** 返回的模型 SHALL 包含更新后的 unified_id
|
||||
|
||||
#### Scenario: 更新模型为重复组合
|
||||
|
||||
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,更新 provider_id 或 model_name 导致与已有记录重复
|
||||
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
|
||||
|
||||
### Requirement: 删除模型配置
|
||||
|
||||
网关 SHALL 允许删除模型配置。
|
||||
|
||||
#### Scenario: 删除存在的模型
|
||||
|
||||
- **WHEN** 向 `/api/models/:id` 发送 DELETE 请求,携带有效的模型 ID
|
||||
- **THEN** 网关 SHALL 删除模型记录
|
||||
- **THEN** 网关 SHALL 返回状态码 204 (No Content)
|
||||
|
||||
### Requirement: 使用 service 层处理业务逻辑
|
||||
|
||||
Handler SHALL 通过 ModelService 处理业务逻辑。
|
||||
|
||||
#### Scenario: 调用 service 方法
|
||||
|
||||
- **WHEN** handler 收到请求
|
||||
- **THEN** SHALL 调用对应的 ModelService 方法(Create、Get、List、Update、Delete)
|
||||
- **THEN** SHALL 使用 domain.Model 类型
|
||||
- **THEN** Create 时 SHALL 调用 `uuid.New()` 生成 id
|
||||
|
||||
#### Scenario: 供应商验证和唯一性校验
|
||||
|
||||
- **WHEN** 创建或更新模型
|
||||
- **THEN** SHALL 在 service 层验证供应商存在
|
||||
- **THEN** SHALL 在 service 层验证 provider_id + model_name 联合唯一
|
||||
|
||||
### Requirement: 使用 repository 层访问数据
|
||||
|
||||
Service SHALL 通过 ModelRepository 访问数据。
|
||||
|
||||
#### Scenario: 联合查询
|
||||
|
||||
- **WHEN** service 需要按 provider 和 model_name 查询模型
|
||||
- **THEN** SHALL 调用 `FindByProviderAndModelName(providerID, modelName)` 方法
|
||||
|
||||
#### Scenario: 查询所有启用模型
|
||||
|
||||
- **WHEN** proxy handler 需要聚合模型列表
|
||||
- **THEN** SHALL 调用 `ListEnabled()` 方法,返回所有 enabled 的模型(关联 enabled 的供应商)
|
||||
@@ -1,71 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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"`,其余字段原样保留
|
||||
@@ -1,89 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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 字段位置进行改写
|
||||
@@ -1,35 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 创建供应商配置
|
||||
|
||||
网关 SHALL 允许通过管理 API 创建新的供应商配置。
|
||||
|
||||
#### Scenario: 使用有效数据创建供应商
|
||||
|
||||
- **WHEN** 向 `/api/providers` 发送 POST 请求,携带有效的供应商数据(id, name, api_key, base_url, protocol)
|
||||
- **THEN** 网关 SHALL 在数据库中创建新的供应商记录
|
||||
- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201
|
||||
- **THEN** 供应商 SHALL 默认启用
|
||||
- **THEN** protocol 字段 SHALL 默认为 "openai"
|
||||
|
||||
#### Scenario: 使用重复 ID 创建供应商
|
||||
|
||||
- **WHEN** 向 `/api/providers` 发送 POST 请求,携带已存在的 ID
|
||||
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
|
||||
|
||||
#### Scenario: 创建供应商时缺少必需字段
|
||||
|
||||
- **WHEN** 向 `/api/providers` 发送 POST 请求,缺少必需字段(id, name, api_key 或 base_url)
|
||||
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
|
||||
- **THEN** 错误 SHALL 指示缺少哪些字段
|
||||
|
||||
#### 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)
|
||||
@@ -1,39 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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 错误
|
||||
@@ -1,123 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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 请求或请求体为空
|
||||
- **THEN** SHALL 返回错误响应,状态码为 400,提示缺少 model 字段
|
||||
|
||||
#### Scenario: 无效的统一模型 ID
|
||||
|
||||
- **WHEN** 请求体中 `model` 字段不是有效的统一模型 ID 格式
|
||||
- **THEN** SHALL 返回错误响应,状态码为 400
|
||||
|
||||
#### 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_name,OwnedBy 字段为 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_name,OwnedBy 字段为 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)`
|
||||
@@ -1,16 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 使用统计记录统一模型标识
|
||||
|
||||
系统 SHALL 使用 providerID 和 modelName(上游模型名)记录使用统计。
|
||||
|
||||
#### Scenario: 代理请求统计记录
|
||||
|
||||
- **WHEN** 代理请求成功完成
|
||||
- **THEN** SHALL 记录 provider_id 和 model_name 到 usage_stats 表(参数来自路由结果)
|
||||
- **THEN** SHALL 异步执行,不阻塞响应
|
||||
|
||||
#### Scenario: 查询统计
|
||||
|
||||
- **WHEN** 查询统计数据
|
||||
- **THEN** 支持按 provider_id 和 model_name 过滤
|
||||
@@ -1,53 +0,0 @@
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [ ] 1.1 新增迁移脚本:DROP 旧 models 表 + CREATE 新 models 表(id UUID PK, provider_id, model_name, enabled, created_at),UNIQUE(provider_id, model_name)
|
||||
- [ ] 1.2 更新 config/models.go:Model 结构体适配(id 改为 UUID 自动生成,model_name 保持不变)
|
||||
- [ ] 1.3 编写迁移脚本测试
|
||||
|
||||
## 2. 统一模型 ID 工具包
|
||||
|
||||
- [ ] 2.1 新增 pkg/modelid/model_id.go:实现 ParseUnifiedModelID、FormatUnifiedModelID、ValidateProviderID、IsValidUnifiedModelID
|
||||
- [ ] 2.2 新增 pkg/modelid/model_id_test.go:覆盖标准格式、含斜杠 model_name、空字符串、非法字符等边界情况
|
||||
|
||||
## 3. Domain 层适配
|
||||
|
||||
- [ ] 3.1 修改 domain/model.go:Model 结构体字段适配,新增 UnifiedModelID() 方法
|
||||
- [ ] 3.2 修改 domain/route.go:RouteResult 适配新字段
|
||||
|
||||
## 4. Repository 层适配
|
||||
|
||||
- [ ] 4.1 修改 repository/model_repo.go:接口变更 — GetByModelName 改为 FindByProviderAndModelName,新增 ListEnabled
|
||||
- [ ] 4.2 修改 repository/model_repo_impl.go:实现 FindByProviderAndModelName(WHERE provider_id=? AND model_name=?)、ListEnabled(JOIN providers WHERE enabled)
|
||||
- [ ] 4.3 编写 repository 层测试
|
||||
|
||||
## 5. Service 层适配
|
||||
|
||||
- [ ] 5.1 修改 service/routing_service.go:Route 接口改为 RouteByModelName(providerID, modelName string)
|
||||
- [ ] 5.2 修改 service/routing_service_impl.go:调用 FindByProviderAndModelName 替代 GetByModelName
|
||||
- [ ] 5.3 修改 service/model_service.go:Create 生成 UUID、新增联合唯一校验方法
|
||||
- [ ] 5.4 修改 service/model_service_impl.go:实现联合唯一校验、UUID 生成
|
||||
- [ ] 5.5 修改 service/provider_service_impl.go:Create 时调用 ValidateProviderID 校验 ID 字符集
|
||||
- [ ] 5.6 编写 service 层测试
|
||||
|
||||
## 6. Conversion 层适配
|
||||
|
||||
- [ ] 6.1 修改 conversion/adapter.go:ProtocolAdapter 接口新增 ExtractUnifiedModelID、ExtractModelName、RewriteRequestModelName、RewriteResponseModelName 四个方法
|
||||
- [ ] 6.2 修改 conversion/engine.go:ConvertHttpResponse 新增 modelOverride 参数(跨协议场景),各 convert*ResponseBody 中覆写 canonical Model;CreateStreamConverter 新增 modelOverride 参数
|
||||
- [ ] 6.3 修改 conversion/openai/adapter.go:实现 ExtractUnifiedModelID、ExtractModelName(按 ifaceType 提取 model)、RewriteRequestModelName 和 RewriteResponseModelName(json.RawMessage 最小化改写,按 ifaceType 定位 model 字段,请求/响应独立实现),修改 isModelInfoPath 允许 suffix 含 "/"
|
||||
- [ ] 6.4 修改 conversion/anthropic/adapter.go:实现 ExtractUnifiedModelID、ExtractModelName、RewriteRequestModelName、RewriteResponseModelName,修改 isModelInfoPath 允许 suffix 含 "/"
|
||||
- [ ] 6.5 编写 conversion 层测试:ExtractUnifiedModelID、ExtractModelName 各 ifaceType、RewriteRequestModelName/RewriteResponseModelName 保真性(含未知参数不丢失)、isModelInfoPath 含斜杠路径、modelOverride 覆写
|
||||
|
||||
## 7. Handler 层改造
|
||||
|
||||
- [ ] 7.1 修改 handler/proxy_handler.go:HandleProxy 按接口类型分发 — Models/ModelInfo 本地聚合;Chat/Embed/Rerank 用 adapter.ExtractModelName 提取统一 ID 路由,同协议走 Smart Passthrough(adapter.RewriteRequestModelName 改写请求、adapter.RewriteResponseModelName 改写响应),跨协议走全量转换(modelOverride);删除 forwardPassthrough 和硬编码的 extractModelName
|
||||
- [ ] 7.2 修改 handler/model_handler.go:请求体字段适配(移除 id 输入、保留 provider_id 和 model_name),响应新增 unified_id,Create 使用 UUID
|
||||
- [ ] 7.3 修改 handler/provider_handler.go:CreateProvider 校验 ID 字符集
|
||||
- [ ] 7.4 编写 handler 层测试:统一模型 ID 路由、同协议 Smart Passthrough 保真性、跨协议 modelOverride、Models 聚合、ModelInfo 查询、流式场景 model 覆写、provider ID 校验
|
||||
|
||||
## 8. 路由注册适配
|
||||
|
||||
- [ ] 8.1 修改 cmd/server/main.go:setupRoutes 适配 handler 签名变更,传递新增依赖
|
||||
|
||||
## 9. 文档更新
|
||||
|
||||
- [ ] 9.1 按需更新 README.md:同步 models 表结构、API 接口字段、统一模型 ID 格式、Smart Passthrough 策略等变更说明
|
||||
@@ -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)
|
||||
|
||||
@@ -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 表,包含字段:id(TEXT PRIMARY KEY,存储 UUID 字符串)、provider_id(TEXT NOT NULL)、model_name(TEXT NOT NULL)、enabled(INTEGER DEFAULT 1)、created_at(DATETIME)
|
||||
- **THEN** SHALL 存在 UNIQUE(provider_id, model_name) 约束
|
||||
- **THEN** SHALL 存在 FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
|
||||
|
||||
### Requirement: 迁移命令集成
|
||||
|
||||
|
||||
209
openspec/specs/error-responses/spec.md
Normal file
209
openspec/specs/error-responses/spec.md
Normal 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"
|
||||
}
|
||||
```
|
||||
@@ -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 的供应商)
|
||||
|
||||
@@ -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"`,其余字段原样保留
|
||||
|
||||
@@ -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 字段位置进行改写
|
||||
|
||||
@@ -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 是主键,用于构建统一模型 ID(provider_id/model_name)
|
||||
- 修改 ID 会导致所有统一模型 ID 失效
|
||||
- 客户端缓存的模型 ID 全部失效
|
||||
- 如需修改,应创建新供应商并迁移模型
|
||||
|
||||
### Requirement: 列出所有供应商
|
||||
|
||||
|
||||
@@ -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 返回友好的验证错误响应。
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
## ADDED Requirements
|
||||
# Unified Model ID
|
||||
|
||||
## Purpose
|
||||
|
||||
定义统一模型 ID 的格式、解析、格式化和校验规则,确保跨协议的模型标识一致性。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 解析统一模型 ID
|
||||
|
||||
@@ -105,4 +105,125 @@ ProxyHandler SHALL 支持 GET 请求的扩展层接口代理。
|
||||
- **THEN** SHALL 调用 engine.convertHttpRequest(GET 请求 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_name,OwnedBy 字段为 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_name,OwnedBy 字段为 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)`
|
||||
|
||||
@@ -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: 按供应商查询统计
|
||||
|
||||
|
||||
Reference in New Issue
Block a user