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:
@@ -60,13 +60,23 @@ type mockProxyRoutingService struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockProxyRoutingService) Route(modelName string) (*domain.RouteResult, error) {
|
||||
func (m *mockProxyRoutingService) RouteByModelName(providerID, modelName string) (*domain.RouteResult, error) {
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
type mockProxyProviderService struct {
|
||||
providers []domain.Provider
|
||||
err error
|
||||
providers []domain.Provider
|
||||
err error
|
||||
enabledModels []domain.Model
|
||||
modelByProvName *domain.Model
|
||||
}
|
||||
|
||||
func (m *mockProxyProviderService) ListEnabledModels() ([]domain.Model, error) {
|
||||
return m.enabledModels, nil
|
||||
}
|
||||
|
||||
func (m *mockProxyProviderService) GetModelByProviderAndName(providerID, modelName string) (*domain.Model, error) {
|
||||
return m.modelByProvName, nil
|
||||
}
|
||||
|
||||
func (m *mockProxyProviderService) Create(p *domain.Provider) error { return nil }
|
||||
@@ -319,7 +329,8 @@ func TestProxyHandler_ForwardPassthrough_NoProviders(t *testing.T) {
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 404, w.Code)
|
||||
// Models 接口现在本地聚合,返回空列表 200
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
func TestExtractHeaders(t *testing.T) {
|
||||
@@ -716,58 +727,6 @@ func TestProxyHandler_HandleProxy_RouteEmptyBody_NoModel(t *testing.T) {
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
// ============ extractModelName 测试 ============
|
||||
|
||||
func TestExtractModelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "valid model",
|
||||
body: []byte(`{"model": "gpt-4", "messages": []}`),
|
||||
expected: "gpt-4",
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: []byte(`{}`),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
body: []byte(`{invalid}`),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nested structure",
|
||||
body: []byte(`{"model": "claude-3", "messages": [{"role": "user", "content": "hello"}]}`),
|
||||
expected: "claude-3",
|
||||
},
|
||||
{
|
||||
name: "model with special chars",
|
||||
body: []byte(`{"model": "gpt-4-0125-preview", "stream": true}`),
|
||||
expected: "gpt-4-0125-preview",
|
||||
},
|
||||
{
|
||||
name: "empty body bytes",
|
||||
body: []byte{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "model is null",
|
||||
body: []byte(`{"model": null}`),
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractModelName(tt.body)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============ isStreamRequest 测试 ============
|
||||
|
||||
func TestIsStreamRequest(t *testing.T) {
|
||||
@@ -831,3 +790,270 @@ func TestIsStreamRequest(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Models / ModelInfo 本地聚合测试 ============
|
||||
|
||||
func TestProxyHandler_HandleProxy_Models_LocalAggregation(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
providerSvc := &mockProxyProviderService{
|
||||
enabledModels: []domain.Model{
|
||||
{ID: "m1", ProviderID: "openai", ModelName: "gpt-4", Enabled: true},
|
||||
{ID: "m2", ProviderID: "anthropic", ModelName: "claude-3", Enabled: true},
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, providerSvc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
data, ok := resp["data"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, data, 2)
|
||||
|
||||
// 验证统一模型 ID 格式
|
||||
first := data[0].(map[string]interface{})
|
||||
assert.Equal(t, "openai/gpt-4", first["id"])
|
||||
}
|
||||
|
||||
func TestProxyHandler_HandleProxy_ModelInfo_LocalQuery(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
providerSvc := &mockProxyProviderService{
|
||||
modelByProvName: &domain.Model{ID: "m1", ProviderID: "openai", ModelName: "gpt-4", Enabled: true},
|
||||
}
|
||||
h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, providerSvc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/openai/gpt-4"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models/openai/gpt-4", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "openai/gpt-4", resp["id"])
|
||||
}
|
||||
|
||||
func TestProxyHandler_HandleProxy_Models_EmptySuffix_ForwardPassthrough(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
providerSvc := &mockProxyProviderService{
|
||||
providers: []domain.Provider{
|
||||
{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai"},
|
||||
},
|
||||
}
|
||||
client := &mockProxyProviderClient{
|
||||
sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
|
||||
return &conversion.HTTPResponseSpec{
|
||||
StatusCode: 200,
|
||||
Body: []byte(`{"object":"list","data":[]}`),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, client, &mockProxyRoutingService{err: appErrors.ErrModelNotFound}, providerSvc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models/"}}
|
||||
c.Request = httptest.NewRequest("GET", "/openai/v1/models/", nil)
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
|
||||
// ============ Smart Passthrough 统一模型 ID 路由测试 ============
|
||||
|
||||
func TestProxyHandler_HandleProxy_SmartPassthrough_UnifiedID(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
routingSvc := &mockProxyRoutingService{
|
||||
result: &domain.RouteResult{
|
||||
Provider: &domain.Provider{ID: "openai_p", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
|
||||
Model: &domain.Model{ID: "m1", ProviderID: "openai_p", ModelName: "gpt-4", Enabled: true},
|
||||
},
|
||||
}
|
||||
client := &mockProxyProviderClient{
|
||||
sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
|
||||
// 验证请求体中的 model 已被改写为上游模型名
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal(spec.Body, &req)
|
||||
assert.Equal(t, "gpt-4", req["model"])
|
||||
|
||||
return &conversion.HTTPResponseSpec{
|
||||
StatusCode: 200,
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(`{"id":"resp-1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}`),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// 客户端发送统一模型 ID
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
// 验证响应中的 model 已被改写为统一模型 ID
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "openai_p/gpt-4", resp["model"])
|
||||
}
|
||||
|
||||
// ============ 跨协议统一模型 ID 路由测试 ============
|
||||
|
||||
func TestProxyHandler_HandleProxy_CrossProtocol_NonStream_UnifiedID(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
routingSvc := &mockProxyRoutingService{
|
||||
result: &domain.RouteResult{
|
||||
Provider: &domain.Provider{ID: "anthropic_p", Name: "Anthropic", APIKey: "sk-test", BaseURL: "https://api.anthropic.com", Protocol: "anthropic", Enabled: true},
|
||||
Model: &domain.Model{ID: "m1", ProviderID: "anthropic_p", ModelName: "claude-3", Enabled: true},
|
||||
},
|
||||
}
|
||||
client := &mockProxyProviderClient{
|
||||
sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
|
||||
return &conversion.HTTPResponseSpec{
|
||||
StatusCode: 200,
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(`{"id":"msg-1","type":"message","role":"assistant","model":"claude-3","content":[{"type":"text","text":"Hello"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":3}}`),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// OpenAI 客户端使用统一模型 ID 路由到 Anthropic 供应商
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
// 验证跨协议转换后响应中的 model 被覆写为统一模型 ID
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "anthropic_p/claude-3", resp["model"])
|
||||
}
|
||||
|
||||
func TestProxyHandler_HandleProxy_CrossProtocol_Stream_UnifiedID(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
routingSvc := &mockProxyRoutingService{
|
||||
result: &domain.RouteResult{
|
||||
Provider: &domain.Provider{ID: "anthropic_p", Name: "Anthropic", APIKey: "sk-test", BaseURL: "https://api.anthropic.com", Protocol: "anthropic", Enabled: true},
|
||||
Model: &domain.Model{ID: "m1", ProviderID: "anthropic_p", ModelName: "claude-3", Enabled: true},
|
||||
},
|
||||
}
|
||||
client := &mockProxyProviderClient{
|
||||
sendStreamFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) {
|
||||
ch := make(chan provider.StreamEvent, 10)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
ch <- provider.StreamEvent{Data: []byte(`event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg-1","type":"message","role":"assistant","model":"claude-3","content":[]}}
|
||||
|
||||
`)}
|
||||
ch <- provider.StreamEvent{Data: []byte(`event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}
|
||||
|
||||
`)}
|
||||
ch <- provider.StreamEvent{Data: []byte(`event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
`)}
|
||||
ch <- provider.StreamEvent{Done: true}
|
||||
}()
|
||||
return ch, nil
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"anthropic_p/claude-3","messages":[{"role":"user","content":"hi"}],"stream":true}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type"))
|
||||
|
||||
body := w.Body.String()
|
||||
// 验证跨协议流式中 model 被覆写为统一模型 ID
|
||||
assert.Contains(t, body, "anthropic_p/claude-3", "跨协议流式响应中 model 应被覆写为统一模型 ID")
|
||||
}
|
||||
|
||||
func TestProxyHandler_HandleProxy_SmartPassthrough_Fidelity(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
routingSvc := &mockProxyRoutingService{
|
||||
result: &domain.RouteResult{
|
||||
Provider: &domain.Provider{ID: "openai_p", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true},
|
||||
Model: &domain.Model{ID: "m1", ProviderID: "openai_p", ModelName: "gpt-4", Enabled: true},
|
||||
},
|
||||
}
|
||||
var capturedRequestBody []byte
|
||||
client := &mockProxyProviderClient{
|
||||
sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) {
|
||||
capturedRequestBody = spec.Body
|
||||
return &conversion.HTTPResponseSpec{
|
||||
StatusCode: 200,
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(`{"id":"resp-1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8},"unknown_field":"preserved"}`),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// 包含未知参数,验证 Smart Passthrough 保真性
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"openai_p/gpt-4","messages":[{"role":"user","content":"hi"}],"custom_param":"should_be_preserved"}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
// 验证请求中 model 被改写为上游模型名,但未知参数保留
|
||||
var reqBody map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(capturedRequestBody, &reqBody))
|
||||
assert.Equal(t, "gpt-4", reqBody["model"], "请求中 model 应被改写为上游模型名")
|
||||
assert.Equal(t, "should_be_preserved", reqBody["custom_param"], "Smart Passthrough 应保留未知参数")
|
||||
|
||||
// 验证响应中 model 被改写为统一模型 ID,但未知参数保留
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "openai_p/gpt-4", resp["model"], "响应中 model 应被改写为统一模型 ID")
|
||||
assert.Equal(t, "preserved", resp["unknown_field"], "Smart Passthrough 应保留未知响应字段")
|
||||
}
|
||||
|
||||
func TestProxyHandler_HandleProxy_UnifiedID_ModelNotFound(t *testing.T) {
|
||||
engine := setupProxyEngine(t)
|
||||
routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound}
|
||||
providerSvc := &mockProxyProviderService{
|
||||
providers: []domain.Provider{
|
||||
{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai"},
|
||||
},
|
||||
}
|
||||
h := newTestProxyHandler(engine, &mockProxyProviderClient{}, routingSvc, providerSvc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}}
|
||||
// 使用统一模型 ID 格式但模型不存在
|
||||
c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown/model","messages":[{"role":"user","content":"hi"}]}`)))
|
||||
|
||||
h.HandleProxy(c)
|
||||
assert.Equal(t, 404, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Contains(t, resp, "error")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user