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:
@@ -14,10 +14,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/conversion"
|
||||
"nex/backend/internal/conversion/anthropic"
|
||||
openaiConv "nex/backend/internal/conversion/openai"
|
||||
@@ -43,11 +41,7 @@ func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server)
|
||||
w.Write([]byte(`{"error":"not mocked"}`))
|
||||
}))
|
||||
|
||||
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)
|
||||
db := setupTestDB(t)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
@@ -60,7 +54,7 @@ func setupConversionTest(t *testing.T) (*gin.Engine, *gorm.DB, *httptest.Server)
|
||||
modelRepo := repository.NewModelRepository(db)
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
|
||||
providerService := service.NewProviderService(providerRepo)
|
||||
providerService := service.NewProviderService(providerRepo, modelRepo)
|
||||
modelService := service.NewModelService(modelRepo, providerRepo)
|
||||
routingService := service.NewRoutingService(modelRepo, providerRepo)
|
||||
statsService := service.NewStatsService(statsRepo)
|
||||
@@ -125,7 +119,7 @@ func createProviderAndModel(t *testing.T, r *gin.Engine, providerID, protocol, m
|
||||
require.Equal(t, 201, w.Code)
|
||||
|
||||
modelBody, _ := json.Marshal(map[string]string{
|
||||
"id": modelName,
|
||||
|
||||
"provider_id": providerID,
|
||||
"model_name": modelName,
|
||||
})
|
||||
@@ -156,7 +150,7 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-3-opus",
|
||||
"model": "anthropic_p/claude-3-opus",
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "Hello from Anthropic!"},
|
||||
},
|
||||
@@ -170,11 +164,11 @@ func TestConversion_OpenAIToAnthropic_NonStream(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
createProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-3-opus", upstream.URL)
|
||||
createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL)
|
||||
|
||||
// 使用 OpenAI 格式发送请求
|
||||
openaiReq := map[string]any{
|
||||
"model": "claude-3-opus",
|
||||
"model": "anthropic_p/claude-3-opus",
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": "Hello"},
|
||||
},
|
||||
@@ -233,10 +227,10 @@ func TestConversion_AnthropicToOpenAI_NonStream(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
createProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL)
|
||||
createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL)
|
||||
|
||||
anthropicReq := map[string]any{
|
||||
"model": "gpt-4",
|
||||
"model": "openai_p/gpt-4",
|
||||
"max_tokens": 1024,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -273,16 +267,18 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
// Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名
|
||||
assert.Equal(t, "gpt-4", req["model"])
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 上游返回上游模型名
|
||||
w.Write([]byte(`{"id":"chatcmpl-pass","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"passthrough"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}`))
|
||||
})
|
||||
|
||||
createProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL)
|
||||
createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-4",
|
||||
"model": "openai_p/gpt-4", // 客户端发送统一 ID
|
||||
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
@@ -293,7 +289,8 @@ func TestConversion_OpenAIToOpenAI_Passthrough(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "passthrough")
|
||||
// Smart Passthrough: 响应体中的上游模型名应被改写为统一 ID
|
||||
assert.Contains(t, w.Body.String(), `"model":"openai_p/gpt-4"`)
|
||||
}
|
||||
|
||||
func TestConversion_AnthropicToAnthropic_Passthrough(t *testing.T) {
|
||||
@@ -302,14 +299,21 @@ func TestConversion_AnthropicToAnthropic_Passthrough(t *testing.T) {
|
||||
upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/v1/messages", r.URL.Path)
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
json.Unmarshal(body, &req)
|
||||
// Smart Passthrough: 请求体中的统一 ID 应被改写为上游模型名
|
||||
assert.Equal(t, "claude-3-opus", req["model"])
|
||||
|
||||
// 上游返回上游模型名
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":"msg-pass","type":"message","role":"assistant","model":"claude-3-opus","content":[{"type":"text","text":"passthrough"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":1}}`))
|
||||
})
|
||||
|
||||
createProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-3-opus", upstream.URL)
|
||||
createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "claude-3-opus",
|
||||
"model": "anthropic_p/claude-3-opus", // 客户端发送统一 ID
|
||||
"max_tokens": 1024,
|
||||
"messages": []map[string]any{{"role": "user", "content": "test"}},
|
||||
}
|
||||
@@ -321,7 +325,8 @@ func TestConversion_AnthropicToAnthropic_Passthrough(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "passthrough")
|
||||
// Smart Passthrough: 响应体中的上游模型名应被改写为统一 ID
|
||||
assert.Contains(t, w.Body.String(), `"model":"anthropic_p/claude-3-opus"`)
|
||||
}
|
||||
|
||||
// ============ 流式转换测试 ============
|
||||
@@ -349,10 +354,10 @@ func TestConversion_OpenAIToAnthropic_Stream(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
createProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-3-opus", upstream.URL)
|
||||
createProviderAndModel(t, r, "anthropic_p", "anthropic", "claude-3-opus", upstream.URL)
|
||||
|
||||
openaiReq := map[string]any{
|
||||
"model": "claude-3-opus",
|
||||
"model": "anthropic_p/claude-3-opus",
|
||||
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
|
||||
"stream": true,
|
||||
}
|
||||
@@ -390,10 +395,10 @@ func TestConversion_AnthropicToOpenAI_Stream(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
createProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL)
|
||||
createProviderAndModel(t, r, "openai_p", "openai", "gpt-4", upstream.URL)
|
||||
|
||||
anthropicReq := map[string]any{
|
||||
"model": "gpt-4",
|
||||
"model": "openai_p/gpt-4",
|
||||
"max_tokens": 1024,
|
||||
"messages": []map[string]any{{"role": "user", "content": "Hello"}},
|
||||
"stream": true,
|
||||
@@ -512,7 +517,7 @@ func TestConversion_ProviderWithProtocol(t *testing.T) {
|
||||
|
||||
// 创建带 protocol 字段的 provider
|
||||
providerBody := map[string]any{
|
||||
"id": "test-protocol",
|
||||
"id": "test_protocol",
|
||||
"name": "Test Protocol",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://test.com",
|
||||
@@ -533,7 +538,7 @@ func TestConversion_ProviderWithProtocol(t *testing.T) {
|
||||
|
||||
// 获取时应包含 protocol
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/api/providers/test-protocol", nil)
|
||||
req = httptest.NewRequest("GET", "/api/providers/test_protocol", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
@@ -547,7 +552,7 @@ func TestConversion_ProviderDefaultProtocol(t *testing.T) {
|
||||
|
||||
// 不指定 protocol,默认应为 openai
|
||||
providerBody := map[string]any{
|
||||
"id": "default-proto",
|
||||
"id": "default_proto",
|
||||
"name": "Default",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://test.com",
|
||||
|
||||
Reference in New Issue
Block a user