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

@@ -9,11 +9,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"nex/backend/internal/config"
"nex/backend/internal/domain"
"nex/backend/internal/handler"
"nex/backend/internal/handler/middleware"
@@ -27,23 +24,13 @@ func init() {
func setupIntegrationTest(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
dir := t.TempDir()
db, err := gorm.Open(sqlite.Open(dir+"/test.db"), &gorm.Config{})
require.NoError(t, err)
err = db.AutoMigrate(&config.Provider{}, &config.Model{}, &config.UsageStats{})
require.NoError(t, err)
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
})
db := setupTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
statsRepo := repository.NewStatsRepository(db)
providerService := service.NewProviderService(providerRepo)
providerService := service.NewProviderService(providerRepo, modelRepo)
modelService := service.NewModelService(modelRepo, providerRepo)
_ = service.NewRoutingService(modelRepo, providerRepo)
statsService := service.NewStatsService(statsRepo)
@@ -97,13 +84,16 @@ func TestOpenAI_CompleteFlow(t *testing.T) {
// 2. 创建 Model
modelBody, _ := json.Marshal(map[string]string{
"id": "gpt4", "provider_id": "openai", "model_name": "gpt-4",
"provider_id": "openai", "model_name": "gpt-4",
})
w = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/models", bytes.NewReader(modelBody))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 201, w.Code)
var createdModel domain.Model
json.Unmarshal(w.Body.Bytes(), &createdModel)
assert.NotEmpty(t, createdModel.ID)
// 3. 列出 Provider
w = httptest.NewRecorder()
@@ -135,7 +125,7 @@ func TestOpenAI_CompleteFlow(t *testing.T) {
// 6. 删除 Model
w = httptest.NewRecorder()
req = httptest.NewRequest("DELETE", "/api/models/gpt4", nil)
req = httptest.NewRequest("DELETE", "/api/models/"+createdModel.ID, nil)
r.ServeHTTP(w, req)
assert.Equal(t, 204, w.Code)
@@ -160,17 +150,19 @@ func TestAnthropic_ModelCreation(t *testing.T) {
assert.Equal(t, 201, w.Code)
modelBody, _ := json.Marshal(map[string]string{
"id": "claude3", "provider_id": "anthropic", "model_name": "claude-3-opus-20240229",
"provider_id": "anthropic", "model_name": "claude-3-opus-20240229",
})
w = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/models", bytes.NewReader(modelBody))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, 201, w.Code)
var createdModel domain.Model
json.Unmarshal(w.Body.Bytes(), &createdModel)
// 验证创建成功
w = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/api/models/claude3", nil)
req = httptest.NewRequest("GET", "/api/models/"+createdModel.ID, nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
}
@@ -188,7 +180,7 @@ func TestStats_RecordingAndQuery(t *testing.T) {
r.ServeHTTP(w, req)
modelBody, _ := json.Marshal(map[string]string{
"id": "m1", "provider_id": "p1", "model_name": "gpt-4",
"provider_id": "p1", "model_name": "gpt-4",
})
w = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/api/models", bytes.NewReader(modelBody))