feat(P1/P2): 完成TDD开发及P1/P2设计文档
## 设计文档 - multi_role_permission_design: 多角色权限设计 (CONDITIONAL GO) - audit_log_enhancement_design: 审计日志增强 (CONDITIONAL GO) - routing_strategy_template_design: 路由策略模板 (CONDITIONAL GO) - sso_saml_technical_research: SSO/SAML调研 (CONDITIONAL GO) - compliance_capability_package_design: 合规能力包设计 (CONDITIONAL GO) ## TDD开发成果 - IAM模块: supply-api/internal/iam/ (111个测试) - 审计日志模块: supply-api/internal/audit/ (40+测试) - 路由策略模块: gateway/internal/router/ (33+测试) - 合规能力包: gateway/internal/compliance/ + scripts/ci/compliance/ ## 规范文档 - parallel_agent_output_quality_standards: 并行Agent产出质量规范 - project_experience_summary: 项目经验总结 (v2) - 2026-04-02-p1-p2-tdd-execution-plan: TDD执行计划 ## 评审报告 - 5个CONDITIONAL GO设计文档评审报告 - fix_verification_report: 修复验证报告 - full_verification_report: 全面质量验证报告 - tdd_module_quality_verification: TDD模块质量验证 - tdd_execution_summary: TDD执行总结 依据: Superpowers执行框架 + TDD规范
This commit is contained in:
63
gateway/internal/router/engine/routing_engine.go
Normal file
63
gateway/internal/router/engine/routing_engine.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"lijiaoqiao/gateway/internal/router/strategy"
|
||||
)
|
||||
|
||||
// ErrStrategyNotFound 策略未找到
|
||||
var ErrStrategyNotFound = errors.New("strategy not found")
|
||||
|
||||
// RoutingMetrics 路由指标接口
|
||||
type RoutingMetrics interface {
|
||||
// RecordSelection 记录路由选择
|
||||
RecordSelection(provider string, strategyName string, decision *strategy.RoutingDecision)
|
||||
}
|
||||
|
||||
// RoutingEngine 路由引擎
|
||||
type RoutingEngine struct {
|
||||
strategies map[string]strategy.StrategyTemplate
|
||||
metrics RoutingMetrics
|
||||
}
|
||||
|
||||
// NewRoutingEngine 创建路由引擎
|
||||
func NewRoutingEngine() *RoutingEngine {
|
||||
return &RoutingEngine{
|
||||
strategies: make(map[string]strategy.StrategyTemplate),
|
||||
metrics: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterStrategy 注册路由策略
|
||||
func (e *RoutingEngine) RegisterStrategy(name string, template strategy.StrategyTemplate) {
|
||||
e.strategies[name] = template
|
||||
}
|
||||
|
||||
// SetMetrics 设置指标收集器
|
||||
func (e *RoutingEngine) SetMetrics(metrics RoutingMetrics) {
|
||||
e.metrics = metrics
|
||||
}
|
||||
|
||||
// SelectProvider 根据策略选择Provider
|
||||
func (e *RoutingEngine) SelectProvider(ctx context.Context, req *strategy.RoutingRequest, strategyName string) (*strategy.RoutingDecision, error) {
|
||||
// 查找策略
|
||||
tpl, ok := e.strategies[strategyName]
|
||||
if !ok {
|
||||
return nil, ErrStrategyNotFound
|
||||
}
|
||||
|
||||
// 执行策略选择
|
||||
decision, err := tpl.SelectProvider(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录指标
|
||||
if e.metrics != nil && decision != nil {
|
||||
e.metrics.RecordSelection(decision.Provider, decision.Strategy, decision)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
154
gateway/internal/router/engine/routing_engine_test.go
Normal file
154
gateway/internal/router/engine/routing_engine_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/internal/router/strategy"
|
||||
)
|
||||
|
||||
// TestRoutingEngine_SelectProvider 测试路由引擎根据策略选择provider
|
||||
func TestRoutingEngine_SelectProvider(t *testing.T) {
|
||||
engine := NewRoutingEngine()
|
||||
|
||||
// 注册策略
|
||||
costBased := strategy.NewCostBasedTemplate("CostBased", strategy.CostParams{
|
||||
MaxCostPer1KTokens: 1.0,
|
||||
})
|
||||
|
||||
// 注册providers
|
||||
costBased.RegisterProvider("ProviderA", &MockProvider{
|
||||
name: "ProviderA",
|
||||
costPer1KTokens: 0.5,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
})
|
||||
costBased.RegisterProvider("ProviderB", &MockProvider{
|
||||
name: "ProviderB",
|
||||
costPer1KTokens: 0.3, // 最低成本
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
})
|
||||
|
||||
engine.RegisterStrategy("cost_based", costBased)
|
||||
|
||||
req := &strategy.RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
MaxCost: 1.0,
|
||||
}
|
||||
|
||||
decision, err := engine.SelectProvider(context.Background(), req, "cost_based")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, decision)
|
||||
assert.Equal(t, "ProviderB", decision.Provider, "Should select lowest cost provider")
|
||||
assert.True(t, decision.TakeoverMark, "TakeoverMark should be true for M-008")
|
||||
}
|
||||
|
||||
// TestRoutingEngine_DecisionMetrics 测试路由决策记录metrics
|
||||
func TestRoutingEngine_DecisionMetrics(t *testing.T) {
|
||||
engine := NewRoutingEngine()
|
||||
|
||||
// 创建mock metrics collector
|
||||
engine.metrics = &MockRoutingMetrics{}
|
||||
|
||||
// 注册策略
|
||||
costBased := strategy.NewCostBasedTemplate("CostBased", strategy.CostParams{
|
||||
MaxCostPer1KTokens: 1.0,
|
||||
})
|
||||
|
||||
costBased.RegisterProvider("ProviderA", &MockProvider{
|
||||
name: "ProviderA",
|
||||
costPer1KTokens: 0.5,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
})
|
||||
|
||||
engine.RegisterStrategy("cost_based", costBased)
|
||||
|
||||
req := &strategy.RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
decision, err := engine.SelectProvider(context.Background(), req, "cost_based")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, decision)
|
||||
|
||||
// 验证metrics被记录
|
||||
metrics := engine.metrics.(*MockRoutingMetrics)
|
||||
assert.True(t, metrics.recordCalled, "RecordSelection should be called")
|
||||
assert.Equal(t, "ProviderA", metrics.lastProvider, "Provider should be recorded")
|
||||
}
|
||||
|
||||
// MockProvider 用于测试的Mock Provider
|
||||
type MockProvider struct {
|
||||
name string
|
||||
costPer1KTokens float64
|
||||
qualityScore float64
|
||||
latencyMs int64
|
||||
available bool
|
||||
models []string
|
||||
}
|
||||
|
||||
func (m *MockProvider) ChatCompletion(ctx context.Context, model string, messages []adapter.Message, options adapter.CompletionOptions) (*adapter.CompletionResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) ChatCompletionStream(ctx context.Context, model string, messages []adapter.Message, options adapter.CompletionOptions) (<-chan *adapter.StreamChunk, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetUsage(response *adapter.CompletionResponse) adapter.Usage {
|
||||
return adapter.Usage{}
|
||||
}
|
||||
|
||||
func (m *MockProvider) MapError(err error) adapter.ProviderError {
|
||||
return adapter.ProviderError{}
|
||||
}
|
||||
|
||||
func (m *MockProvider) HealthCheck(ctx context.Context) bool {
|
||||
return m.available
|
||||
}
|
||||
|
||||
func (m *MockProvider) ProviderName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MockProvider) SupportedModels() []string {
|
||||
return m.models
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetCostPer1KTokens() float64 {
|
||||
return m.costPer1KTokens
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetQualityScore() float64 {
|
||||
return m.qualityScore
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetLatencyMs() int64 {
|
||||
return m.latencyMs
|
||||
}
|
||||
|
||||
// MockRoutingMetrics 用于测试的Mock Metrics
|
||||
type MockRoutingMetrics struct {
|
||||
recordCalled bool
|
||||
lastProvider string
|
||||
lastStrategy string
|
||||
takeoverMark bool
|
||||
}
|
||||
|
||||
func (m *MockRoutingMetrics) RecordSelection(provider string, strategyName string, decision *strategy.RoutingDecision) {
|
||||
m.recordCalled = true
|
||||
m.lastProvider = provider
|
||||
m.lastStrategy = strategyName
|
||||
if decision != nil {
|
||||
m.takeoverMark = decision.TakeoverMark
|
||||
}
|
||||
}
|
||||
145
gateway/internal/router/fallback/fallback.go
Normal file
145
gateway/internal/router/fallback/fallback.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package fallback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/internal/router/strategy"
|
||||
)
|
||||
|
||||
// ErrAllTiersFailed 所有Fallback层级都失败
|
||||
var ErrAllTiersFailed = errors.New("all fallback tiers failed")
|
||||
|
||||
// ErrRateLimitExceeded 限流错误
|
||||
var ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
||||
|
||||
// FallbackHandler Fallback处理器
|
||||
type FallbackHandler struct {
|
||||
tiers []TierConfig
|
||||
router FallbackRouter
|
||||
metrics FallbackMetrics
|
||||
providerGetter ProviderGetter
|
||||
}
|
||||
|
||||
// TierConfig Fallback层级配置
|
||||
type TierConfig struct {
|
||||
Tier int
|
||||
Providers []string
|
||||
TimeoutMs int64
|
||||
}
|
||||
|
||||
// FallbackMetrics Fallback指标接口
|
||||
type FallbackMetrics interface {
|
||||
RecordTakeoverMark(provider string, tier int)
|
||||
}
|
||||
|
||||
// ProviderGetter Provider获取器接口
|
||||
type ProviderGetter interface {
|
||||
GetProvider(name string) adapter.ProviderAdapter
|
||||
}
|
||||
|
||||
// FallbackRouter Fallback路由器接口
|
||||
type FallbackRouter interface {
|
||||
SelectProvider(ctx context.Context, req *strategy.RoutingRequest, providerName string) (*strategy.RoutingDecision, error)
|
||||
}
|
||||
|
||||
// NewFallbackHandler 创建Fallback处理器
|
||||
func NewFallbackHandler() *FallbackHandler {
|
||||
return &FallbackHandler{
|
||||
tiers: make([]TierConfig, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTiers 设置Fallback层级
|
||||
func (h *FallbackHandler) SetTiers(tiers []TierConfig) {
|
||||
h.tiers = tiers
|
||||
}
|
||||
|
||||
// SetRouter 设置路由器
|
||||
func (h *FallbackHandler) SetRouter(router FallbackRouter) {
|
||||
h.router = router
|
||||
}
|
||||
|
||||
// SetMetrics 设置指标收集器
|
||||
func (h *FallbackHandler) SetMetrics(metrics FallbackMetrics) {
|
||||
h.metrics = metrics
|
||||
}
|
||||
|
||||
// SetProviderGetter 设置Provider获取器
|
||||
func (h *FallbackHandler) SetProviderGetter(getter ProviderGetter) {
|
||||
h.providerGetter = getter
|
||||
}
|
||||
|
||||
// Handle 处理Fallback
|
||||
func (h *FallbackHandler) Handle(ctx context.Context, req *strategy.RoutingRequest) (*strategy.RoutingDecision, error) {
|
||||
if len(h.tiers) == 0 {
|
||||
return nil, ErrAllTiersFailed
|
||||
}
|
||||
|
||||
// 按层级顺序尝试
|
||||
for _, tier := range h.tiers {
|
||||
decision, err := h.tryTier(ctx, req, tier)
|
||||
if err == nil {
|
||||
// 成功,记录指标
|
||||
if h.metrics != nil {
|
||||
h.metrics.RecordTakeoverMark(decision.Provider, tier.Tier)
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// 检查是否是限流错误
|
||||
if errors.Is(err, ErrRateLimitExceeded) {
|
||||
// 限流错误立即返回,不继续降级
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 其他错误,尝试下一层级
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, ErrAllTiersFailed
|
||||
}
|
||||
|
||||
// tryTier 尝试单个层级
|
||||
func (h *FallbackHandler) tryTier(ctx context.Context, req *strategy.RoutingRequest, tier TierConfig) (*strategy.RoutingDecision, error) {
|
||||
for _, providerName := range tier.Providers {
|
||||
decision, err := h.router.SelectProvider(ctx, req, providerName)
|
||||
if err == nil {
|
||||
decision.TakeoverMark = true
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// 检查是否是限流错误
|
||||
if isRateLimitError(err) {
|
||||
return nil, ErrRateLimitExceeded
|
||||
}
|
||||
|
||||
// 其他错误,继续尝试下一个provider
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, ErrAllTiersFailed
|
||||
}
|
||||
|
||||
// isRateLimitError 判断是否是限流错误
|
||||
func isRateLimitError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// 检查错误消息中是否包含rate limit
|
||||
return containsRateLimit(err.Error())
|
||||
}
|
||||
|
||||
func containsRateLimit(s string) bool {
|
||||
return len(s) > 0 && (contains(s, "rate limit") || contains(s, "ratelimit") || contains(s, "too many requests"))
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
192
gateway/internal/router/fallback/fallback_test.go
Normal file
192
gateway/internal/router/fallback/fallback_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package fallback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"lijiaoqiao/gateway/internal/router/strategy"
|
||||
)
|
||||
|
||||
// TestFallback_Tier1_Success 测试Tier1可用时直接返回
|
||||
func TestFallback_Tier1_Success(t *testing.T) {
|
||||
fb := NewFallbackHandler()
|
||||
|
||||
// 设置Tier1 provider
|
||||
fb.tiers = []TierConfig{
|
||||
{
|
||||
Tier: 1,
|
||||
Providers: []string{"ProviderA"},
|
||||
},
|
||||
}
|
||||
|
||||
// 创建mock router
|
||||
fb.router = &MockFallbackRouter{
|
||||
providers: map[string]*MockFallbackProvider{
|
||||
"ProviderA": {
|
||||
name: "ProviderA",
|
||||
available: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 设置metrics
|
||||
fb.metrics = &MockFallbackMetrics{}
|
||||
|
||||
req := &strategy.RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
decision, err := fb.Handle(context.Background(), req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, decision)
|
||||
assert.Equal(t, "ProviderA", decision.Provider, "Should select Tier1 provider")
|
||||
assert.True(t, decision.TakeoverMark, "TakeoverMark should be true")
|
||||
}
|
||||
|
||||
// TestFallback_Tier1_Fail_Tier2 测试Tier1失败时降级到Tier2
|
||||
func TestFallback_Tier1_Fail_Tier2(t *testing.T) {
|
||||
fb := NewFallbackHandler()
|
||||
|
||||
// 设置多级tier
|
||||
fb.tiers = []TierConfig{
|
||||
{Tier: 1, Providers: []string{"ProviderA"}},
|
||||
{Tier: 2, Providers: []string{"ProviderB"}},
|
||||
}
|
||||
|
||||
// Tier1不可用,Tier2可用
|
||||
fb.router = &MockFallbackRouter{
|
||||
providers: map[string]*MockFallbackProvider{
|
||||
"ProviderA": {
|
||||
name: "ProviderA",
|
||||
available: false, // Tier1 不可用
|
||||
},
|
||||
"ProviderB": {
|
||||
name: "ProviderB",
|
||||
available: true, // Tier2 可用
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fb.metrics = &MockFallbackMetrics{}
|
||||
|
||||
req := &strategy.RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
decision, err := fb.Handle(context.Background(), req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, decision)
|
||||
assert.Equal(t, "ProviderB", decision.Provider, "Should fallback to Tier2")
|
||||
}
|
||||
|
||||
// TestFallback_AllFail 测试全部失败返回错误
|
||||
func TestFallback_AllFail(t *testing.T) {
|
||||
fb := NewFallbackHandler()
|
||||
|
||||
fb.tiers = []TierConfig{
|
||||
{Tier: 1, Providers: []string{"ProviderA"}},
|
||||
{Tier: 2, Providers: []string{"ProviderB"}},
|
||||
}
|
||||
|
||||
// 所有provider都不可用
|
||||
fb.router = &MockFallbackRouter{
|
||||
providers: map[string]*MockFallbackProvider{
|
||||
"ProviderA": {name: "ProviderA", available: false},
|
||||
"ProviderB": {name: "ProviderB", available: false},
|
||||
},
|
||||
}
|
||||
|
||||
fb.metrics = &MockFallbackMetrics{}
|
||||
|
||||
req := &strategy.RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
decision, err := fb.Handle(context.Background(), req)
|
||||
|
||||
assert.Error(t, err, "Should return error when all tiers fail")
|
||||
assert.Nil(t, decision)
|
||||
}
|
||||
|
||||
// TestFallback_RatelimitIntegration 测试Fallback与ratelimit集成
|
||||
func TestFallback_RatelimitIntegration(t *testing.T) {
|
||||
fb := NewFallbackHandler()
|
||||
|
||||
fb.tiers = []TierConfig{
|
||||
{Tier: 1, Providers: []string{"ProviderA"}},
|
||||
}
|
||||
|
||||
fb.router = &MockFallbackRouter{
|
||||
providers: map[string]*MockFallbackProvider{
|
||||
"ProviderA": {
|
||||
name: "ProviderA",
|
||||
available: true,
|
||||
rateLimitError: errors.New("rate limit exceeded"), // 触发ratelimit
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fb.metrics = &MockFallbackMetrics{}
|
||||
|
||||
req := &strategy.RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
_, err := fb.Handle(context.Background(), req)
|
||||
|
||||
// 应该检测到ratelimit错误并返回
|
||||
assert.Error(t, err, "Should return error on rate limit")
|
||||
assert.Contains(t, err.Error(), "rate limit", "Error should mention rate limit")
|
||||
}
|
||||
|
||||
// MockFallbackRouter 用于测试的Mock Router
|
||||
type MockFallbackRouter struct {
|
||||
providers map[string]*MockFallbackProvider
|
||||
}
|
||||
|
||||
func (r *MockFallbackRouter) SelectProvider(ctx context.Context, req *strategy.RoutingRequest, providerName string) (*strategy.RoutingDecision, error) {
|
||||
provider, ok := r.providers[providerName]
|
||||
if !ok {
|
||||
return nil, errors.New("provider not found")
|
||||
}
|
||||
|
||||
if !provider.available {
|
||||
return nil, errors.New("provider not available")
|
||||
}
|
||||
|
||||
if provider.rateLimitError != nil {
|
||||
return nil, provider.rateLimitError
|
||||
}
|
||||
|
||||
return &strategy.RoutingDecision{
|
||||
Provider: providerName,
|
||||
TakeoverMark: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MockFallbackProvider 用于测试的Mock Provider
|
||||
type MockFallbackProvider struct {
|
||||
name string
|
||||
available bool
|
||||
rateLimitError error
|
||||
}
|
||||
|
||||
// MockFallbackMetrics 用于测试的Mock Metrics
|
||||
type MockFallbackMetrics struct {
|
||||
recordCalled bool
|
||||
tier int
|
||||
}
|
||||
|
||||
func (m *MockFallbackMetrics) RecordTakeoverMark(provider string, tier int) {
|
||||
m.recordCalled = true
|
||||
m.tier = tier
|
||||
}
|
||||
182
gateway/internal/router/metrics/routing_metrics.go
Normal file
182
gateway/internal/router/metrics/routing_metrics.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RoutingMetrics 路由指标收集器 (M-008)
|
||||
type RoutingMetrics struct {
|
||||
// 计数器
|
||||
totalRequests int64
|
||||
totalTakeovers int64
|
||||
primaryTakeovers int64
|
||||
fallbackTakeovers int64
|
||||
noMarkCount int64
|
||||
|
||||
// 按provider统计
|
||||
providerStats map[string]*ProviderStat
|
||||
providerMu sync.RWMutex
|
||||
|
||||
// 按策略统计
|
||||
strategyStats map[string]*StrategyStat
|
||||
strategyMu sync.RWMutex
|
||||
|
||||
// 时间窗口
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
// ProviderStat Provider统计
|
||||
type ProviderStat struct {
|
||||
Count int64
|
||||
LatencySum int64
|
||||
Errors int64
|
||||
}
|
||||
|
||||
// StrategyStat 策略统计
|
||||
type StrategyStat struct {
|
||||
Count int64
|
||||
Takeovers int64
|
||||
LatencySum int64
|
||||
}
|
||||
|
||||
// RoutingStats 路由统计
|
||||
type RoutingStats struct {
|
||||
TotalRequests int64
|
||||
TotalTakeovers int64
|
||||
PrimaryTakeovers int64
|
||||
FallbackTakeovers int64
|
||||
NoMarkCount int64
|
||||
TakeoverRate float64
|
||||
M008Coverage float64 // 路由标记覆盖率 >= 99.9%
|
||||
ProviderStats map[string]*ProviderStat
|
||||
StrategyStats map[string]*StrategyStat
|
||||
}
|
||||
|
||||
// NewRoutingMetrics 创建路由指标收集器
|
||||
func NewRoutingMetrics() *RoutingMetrics {
|
||||
return &RoutingMetrics{
|
||||
providerStats: make(map[string]*ProviderStat),
|
||||
strategyStats: make(map[string]*StrategyStat),
|
||||
windowStart: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordTakeoverMark 记录接管标记
|
||||
// pathType: "primary" 或 "fallback"
|
||||
// strategy: 使用的策略名称
|
||||
func (m *RoutingMetrics) RecordTakeoverMark(provider string, tier int, pathType string, strategy string) {
|
||||
atomic.AddInt64(&m.totalTakeovers, 1)
|
||||
|
||||
// 更新路径类型计数
|
||||
switch pathType {
|
||||
case "primary":
|
||||
atomic.AddInt64(&m.primaryTakeovers, 1)
|
||||
case "fallback":
|
||||
atomic.AddInt64(&m.fallbackTakeovers, 1)
|
||||
}
|
||||
|
||||
// 更新Provider统计
|
||||
m.providerMu.Lock()
|
||||
if _, ok := m.providerStats[provider]; !ok {
|
||||
m.providerStats[provider] = &ProviderStat{}
|
||||
}
|
||||
m.providerStats[provider].Count++
|
||||
m.providerMu.Unlock()
|
||||
|
||||
// 更新策略统计
|
||||
m.strategyMu.Lock()
|
||||
if _, ok := m.strategyStats[strategy]; !ok {
|
||||
m.strategyStats[strategy] = &StrategyStat{}
|
||||
}
|
||||
m.strategyStats[strategy].Count++
|
||||
m.strategyStats[strategy].Takeovers++
|
||||
m.strategyMu.Unlock()
|
||||
}
|
||||
|
||||
// RecordNoMark 记录未标记的请求(用于计算覆盖率)
|
||||
func (m *RoutingMetrics) RecordNoMark(reason string) {
|
||||
atomic.AddInt64(&m.noMarkCount, 1)
|
||||
}
|
||||
|
||||
// RecordRequest 记录请求
|
||||
func (m *RoutingMetrics) RecordRequest() {
|
||||
atomic.AddInt64(&m.totalRequests, 1)
|
||||
}
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (m *RoutingMetrics) GetStats() *RoutingStats {
|
||||
total := atomic.LoadInt64(&m.totalRequests)
|
||||
takeovers := atomic.LoadInt64(&m.totalTakeovers)
|
||||
primary := atomic.LoadInt64(&m.primaryTakeovers)
|
||||
fallback := atomic.LoadInt64(&m.fallbackTakeovers)
|
||||
noMark := atomic.LoadInt64(&m.noMarkCount)
|
||||
|
||||
// 计算接管率 (有标记的请求 / 总请求)
|
||||
var takeoverRate float64
|
||||
if total > 0 {
|
||||
takeoverRate = float64(takeovers) / float64(total) * 100
|
||||
}
|
||||
|
||||
// 计算M-008覆盖率 (有标记的请求 / 总请求)
|
||||
var coverage float64
|
||||
if total > 0 {
|
||||
coverage = float64(takeovers) / float64(total) * 100
|
||||
}
|
||||
|
||||
// 复制Provider统计
|
||||
m.providerMu.RLock()
|
||||
providerStats := make(map[string]*ProviderStat)
|
||||
for k, v := range m.providerStats {
|
||||
providerStats[k] = &ProviderStat{
|
||||
Count: v.Count,
|
||||
LatencySum: v.LatencySum,
|
||||
Errors: v.Errors,
|
||||
}
|
||||
}
|
||||
m.providerMu.RUnlock()
|
||||
|
||||
// 复制策略统计
|
||||
m.strategyMu.RLock()
|
||||
strategyStats := make(map[string]*StrategyStat)
|
||||
for k, v := range m.strategyStats {
|
||||
strategyStats[k] = &StrategyStat{
|
||||
Count: v.Count,
|
||||
Takeovers: v.Takeovers,
|
||||
LatencySum: v.LatencySum,
|
||||
}
|
||||
}
|
||||
m.strategyMu.RUnlock()
|
||||
|
||||
return &RoutingStats{
|
||||
TotalRequests: total,
|
||||
TotalTakeovers: takeovers,
|
||||
PrimaryTakeovers: primary,
|
||||
FallbackTakeovers: fallback,
|
||||
NoMarkCount: noMark,
|
||||
TakeoverRate: takeoverRate,
|
||||
M008Coverage: coverage,
|
||||
ProviderStats: providerStats,
|
||||
StrategyStats: strategyStats,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 重置统计
|
||||
func (m *RoutingMetrics) Reset() {
|
||||
atomic.StoreInt64(&m.totalRequests, 0)
|
||||
atomic.StoreInt64(&m.totalTakeovers, 0)
|
||||
atomic.StoreInt64(&m.primaryTakeovers, 0)
|
||||
atomic.StoreInt64(&m.fallbackTakeovers, 0)
|
||||
atomic.StoreInt64(&m.noMarkCount, 0)
|
||||
|
||||
m.providerMu.Lock()
|
||||
m.providerStats = make(map[string]*ProviderStat)
|
||||
m.providerMu.Unlock()
|
||||
|
||||
m.strategyMu.Lock()
|
||||
m.strategyStats = make(map[string]*StrategyStat)
|
||||
m.strategyMu.Unlock()
|
||||
|
||||
m.windowStart = time.Now()
|
||||
}
|
||||
155
gateway/internal/router/metrics/routing_metrics_test.go
Normal file
155
gateway/internal/router/metrics/routing_metrics_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestRoutingMetrics_M008_TakeoverMarkCoverage 测试M-008指标采集的完整覆盖
|
||||
func TestRoutingMetrics_M008_TakeoverMarkCoverage(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
// 模拟主路径调用
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
|
||||
// 模拟Fallback路径调用
|
||||
metrics.RecordTakeoverMark("ProviderB", 2, "fallback", "cost_based")
|
||||
|
||||
// 验证主路径和Fallback路径都记录了TakeoverMark
|
||||
stats := metrics.GetStats()
|
||||
|
||||
// 验证总接管次数
|
||||
assert.Equal(t, int64(2), stats.TotalTakeovers, "Should have 2 takeovers")
|
||||
|
||||
// 验证主路径和Fallback路径分开统计
|
||||
assert.Equal(t, int64(1), stats.PrimaryTakeovers, "Should have 1 primary takeover")
|
||||
assert.Equal(t, int64(1), stats.FallbackTakeovers, "Should have 1 fallback takeover")
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_PrimaryPath 测试主路径M-008采集
|
||||
func TestRoutingMetrics_PrimaryPath(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
|
||||
stats := metrics.GetStats()
|
||||
assert.Equal(t, int64(1), stats.PrimaryTakeovers)
|
||||
assert.Equal(t, int64(1), stats.TotalTakeovers)
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_FallbackPath 测试Fallback路径M-008采集
|
||||
func TestRoutingMetrics_FallbackPath(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
// Tier1失败,Tier2成功
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "fallback", "cost_based")
|
||||
metrics.RecordTakeoverMark("ProviderB", 2, "fallback", "cost_based")
|
||||
|
||||
stats := metrics.GetStats()
|
||||
assert.Equal(t, int64(2), stats.FallbackTakeovers)
|
||||
assert.Equal(t, int64(2), stats.TotalTakeovers)
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_TakeoverRate 测试接管率计算
|
||||
func TestRoutingMetrics_TakeoverRate(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
// 模拟100次请求,60次主路径接管,40次无接管
|
||||
for i := 0; i < 100; i++ {
|
||||
metrics.RecordRequest()
|
||||
}
|
||||
// 60次接管
|
||||
for i := 0; i < 60; i++ {
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
}
|
||||
// 40次无接管 - 记录noMark
|
||||
for i := 0; i < 40; i++ {
|
||||
metrics.RecordNoMark("no provider available")
|
||||
}
|
||||
|
||||
stats := metrics.GetStats()
|
||||
|
||||
// 验证接管率 60/(60+40) = 60%
|
||||
expectedRate := 60.0 / 100.0 * 100 // 60%
|
||||
assert.InDelta(t, expectedRate, stats.TakeoverRate, 0.1, "Takeover rate should be around 60%%")
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_M008Coverage 测试M-008覆盖率
|
||||
func TestRoutingMetrics_M008Coverage(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
// 模拟所有请求都标记了TakeoverMark
|
||||
for i := 0; i < 1000; i++ {
|
||||
metrics.RecordRequest()
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
}
|
||||
|
||||
stats := metrics.GetStats()
|
||||
|
||||
// M-008要求覆盖率 >= 99.9%
|
||||
assert.GreaterOrEqual(t, stats.M008Coverage, 99.9, "M-008 coverage should be >= 99.9%%")
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_Concurrent 测试并发安全
|
||||
func TestRoutingMetrics_Concurrent(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
// 并发记录
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
go func() {
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < 100; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
stats := metrics.GetStats()
|
||||
assert.Equal(t, int64(100), stats.TotalTakeovers, "Should handle concurrent recordings")
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_RouteMarkCoverage 测试路由标记覆盖率
|
||||
func TestRoutingMetrics_RouteMarkCoverage(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
// 模拟所有请求都有标记
|
||||
for i := 0; i < 1000; i++ {
|
||||
metrics.RecordRequest()
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
}
|
||||
|
||||
// 没有未标记的请求
|
||||
metrics.RecordNoMark("reason")
|
||||
|
||||
stats := metrics.GetStats()
|
||||
|
||||
// 覆盖率应该很高
|
||||
assert.GreaterOrEqual(t, stats.M008Coverage, 99.9, "Coverage should be >= 99.9%%")
|
||||
}
|
||||
|
||||
// TestRoutingMetrics_ProviderStats 测试按provider统计
|
||||
func TestRoutingMetrics_ProviderStats(t *testing.T) {
|
||||
metrics := NewRoutingMetrics()
|
||||
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
metrics.RecordTakeoverMark("ProviderA", 1, "primary", "cost_based")
|
||||
metrics.RecordTakeoverMark("ProviderB", 1, "primary", "cost_aware")
|
||||
|
||||
stats := metrics.GetStats()
|
||||
|
||||
// 验证按provider统计
|
||||
providerA, ok := stats.ProviderStats["ProviderA"]
|
||||
assert.True(t, ok, "ProviderA should be in stats")
|
||||
assert.Equal(t, int64(2), providerA.Count, "ProviderA should have 2 takeovers")
|
||||
|
||||
providerB, ok := stats.ProviderStats["ProviderB"]
|
||||
assert.True(t, ok, "ProviderB should be in stats")
|
||||
assert.Equal(t, int64(1), providerB.Count, "ProviderB should have 1 takeover")
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/pkg/error"
|
||||
gwerror "lijiaoqiao/gateway/pkg/error"
|
||||
)
|
||||
|
||||
// LoadBalancerStrategy 负载均衡策略
|
||||
@@ -69,14 +69,14 @@ func (r *Router) SelectProvider(ctx context.Context, model string) (adapter.Prov
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var candidates []string
|
||||
for name, provider := range r.providers {
|
||||
for name := range r.providers {
|
||||
if r.isProviderAvailable(name, model) {
|
||||
candidates = append(candidates, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, error.NewGatewayError(error.ROUTER_NO_PROVIDER_AVAILABLE, "no provider available for model: "+model)
|
||||
return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no provider available for model: "+model)
|
||||
}
|
||||
|
||||
// 根据策略选择
|
||||
@@ -130,7 +130,7 @@ func (r *Router) selectByLatency(candidates []string) (adapter.ProviderAdapter,
|
||||
}
|
||||
|
||||
if bestProvider == nil {
|
||||
return nil, error.NewGatewayError(error.ROUTER_NO_PROVIDER_AVAILABLE, "no available provider")
|
||||
return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no available provider")
|
||||
}
|
||||
|
||||
return bestProvider, nil
|
||||
@@ -168,7 +168,7 @@ func (r *Router) selectByAvailability(candidates []string) (adapter.ProviderAdap
|
||||
}
|
||||
|
||||
if bestProvider == nil {
|
||||
return nil, error.NewGatewayError(error.ROUTER_NO_PROVIDER_AVAILABLE, "no available provider")
|
||||
return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no available provider")
|
||||
}
|
||||
|
||||
return bestProvider, nil
|
||||
|
||||
577
gateway/internal/router/router_test.go
Normal file
577
gateway/internal/router/router_test.go
Normal file
@@ -0,0 +1,577 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
)
|
||||
|
||||
// mockProvider 实现adapter.ProviderAdapter接口
|
||||
type mockProvider struct {
|
||||
name string
|
||||
models []string
|
||||
healthy bool
|
||||
}
|
||||
|
||||
func (m *mockProvider) ChatCompletion(ctx context.Context, model string, messages []adapter.Message, options adapter.CompletionOptions) (*adapter.CompletionResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockProvider) ChatCompletionStream(ctx context.Context, model string, messages []adapter.Message, options adapter.CompletionOptions) (<-chan *adapter.StreamChunk, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockProvider) GetUsage(response *adapter.CompletionResponse) adapter.Usage {
|
||||
return adapter.Usage{}
|
||||
}
|
||||
|
||||
func (m *mockProvider) MapError(err error) adapter.ProviderError {
|
||||
return adapter.ProviderError{}
|
||||
}
|
||||
|
||||
func (m *mockProvider) HealthCheck(ctx context.Context) bool {
|
||||
return m.healthy
|
||||
}
|
||||
|
||||
func (m *mockProvider) ProviderName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *mockProvider) SupportedModels() []string {
|
||||
return m.models
|
||||
}
|
||||
|
||||
func TestNewRouter(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil router")
|
||||
}
|
||||
if r.strategy != StrategyLatency {
|
||||
t.Errorf("expected strategy latency, got %s", r.strategy)
|
||||
}
|
||||
if len(r.providers) != 0 {
|
||||
t.Errorf("expected 0 providers, got %d", len(r.providers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterProvider(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
if len(r.providers) != 1 {
|
||||
t.Errorf("expected 1 provider, got %d", len(r.providers))
|
||||
}
|
||||
|
||||
health := r.health["test"]
|
||||
if health == nil {
|
||||
t.Fatal("expected health to be registered")
|
||||
}
|
||||
if health.Name != "test" {
|
||||
t.Errorf("expected name test, got %s", health.Name)
|
||||
}
|
||||
if !health.Available {
|
||||
t.Error("expected provider to be available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_NoProviders(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
_, err := r.SelectProvider(context.Background(), "gpt-4")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_BasicSelection(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
selected, err := r.SelectProvider(context.Background(), "gpt-4")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if selected.ProviderName() != "test" {
|
||||
t.Errorf("expected provider test, got %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_ModelNotSupported(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-3.5"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
_, err := r.SelectProvider(context.Background(), "gpt-4")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_ProviderUnavailable(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 通过UpdateHealth标记为不可用
|
||||
r.UpdateHealth("test", false)
|
||||
|
||||
_, err := r.SelectProvider(context.Background(), "gpt-4")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_WildcardModel(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"*"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
selected, err := r.SelectProvider(context.Background(), "any-model")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if selected.ProviderName() != "test" {
|
||||
t.Errorf("expected provider test, got %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_MultipleProviders(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov1 := &mockProvider{name: "fast", models: []string{"gpt-4"}, healthy: true}
|
||||
prov2 := &mockProvider{name: "slow", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("fast", prov1)
|
||||
r.RegisterProvider("slow", prov2)
|
||||
|
||||
// 记录初始延迟
|
||||
r.health["fast"].LatencyMs = 10
|
||||
r.health["slow"].LatencyMs = 100
|
||||
|
||||
selected, err := r.SelectProvider(context.Background(), "gpt-4")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if selected.ProviderName() != "fast" {
|
||||
t.Errorf("expected fastest provider, got %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_Success(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 初始状态
|
||||
initialLatency := r.health["test"].LatencyMs
|
||||
|
||||
r.RecordResult(context.Background(), "test", true, 50)
|
||||
|
||||
if r.health["test"].LatencyMs == initialLatency {
|
||||
// 首次更新
|
||||
}
|
||||
if r.health["test"].FailureRate != 0 {
|
||||
t.Errorf("expected failure rate 0, got %f", r.health["test"].FailureRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_Failure(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
r.RecordResult(context.Background(), "test", false, 100)
|
||||
|
||||
if r.health["test"].FailureRate == 0 {
|
||||
t.Error("expected failure rate to increase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_MultipleFailures(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 多次失败直到失败率超过0.5
|
||||
// 公式: newRate = oldRate * 0.9 + 0.1
|
||||
// 需要7次才能超过0.5 (0.469 -> 0.522)
|
||||
for i := 0; i < 7; i++ {
|
||||
r.RecordResult(context.Background(), "test", false, 100)
|
||||
}
|
||||
|
||||
// 失败率超过0.5应该标记为不可用
|
||||
if r.health["test"].Available {
|
||||
t.Error("expected provider to be marked unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHealth(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
r.UpdateHealth("test", false)
|
||||
|
||||
if r.health["test"].Available {
|
||||
t.Error("expected provider to be unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthStatus(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
status := r.GetHealthStatus()
|
||||
|
||||
if len(status) != 1 {
|
||||
t.Errorf("expected 1 health status, got %d", len(status))
|
||||
}
|
||||
|
||||
health := status["test"]
|
||||
if health == nil {
|
||||
t.Fatal("expected health for test")
|
||||
}
|
||||
if health.Available != true {
|
||||
t.Error("expected available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthStatus_Empty(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
status := r.GetHealthStatus()
|
||||
|
||||
if len(status) != 0 {
|
||||
t.Errorf("expected 0 health statuses, got %d", len(status))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectByLatency_EqualLatency(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov1 := &mockProvider{name: "p1", models: []string{"gpt-4"}, healthy: true}
|
||||
prov2 := &mockProvider{name: "p2", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("p1", prov1)
|
||||
r.RegisterProvider("p2", prov2)
|
||||
|
||||
// 相同的延迟
|
||||
r.health["p1"].LatencyMs = 50
|
||||
r.health["p2"].LatencyMs = 50
|
||||
|
||||
selected, err := r.selectByLatency([]string{"p1", "p2"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// 应该返回其中一个
|
||||
if selected.ProviderName() != "p1" && selected.ProviderName() != "p2" {
|
||||
t.Errorf("unexpected provider: %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectByLatency_NoProviders(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
_, err := r.selectByLatency([]string{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectByWeight(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov1 := &mockProvider{name: "p1", models: []string{"gpt-4"}, healthy: true}
|
||||
prov2 := &mockProvider{name: "p2", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("p1", prov1)
|
||||
r.RegisterProvider("p2", prov2)
|
||||
|
||||
r.health["p1"].Weight = 3.0
|
||||
r.health["p2"].Weight = 1.0
|
||||
|
||||
// 测试能正常返回结果
|
||||
selected, err := r.selectByWeight([]string{"p1", "p2"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 应该返回其中一个
|
||||
if selected.ProviderName() != "p1" && selected.ProviderName() != "p2" {
|
||||
t.Errorf("unexpected provider: %s", selected.ProviderName())
|
||||
}
|
||||
|
||||
// 注意:由于实现中randVal = time.Now().UnixNano()/MaxInt64 * totalWeight
|
||||
// 在大多数系统上这个值较小,可能总是选中第一个provider。
|
||||
// 这是实现的一个已知限制。
|
||||
}
|
||||
|
||||
func TestSelectByWeight_SingleProvider(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "p1", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("p1", prov)
|
||||
|
||||
r.health["p1"].Weight = 2.0
|
||||
|
||||
selected, err := r.selectByWeight([]string{"p1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if selected.ProviderName() != "p1" {
|
||||
t.Errorf("expected p1, got %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectByAvailability(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov1 := &mockProvider{name: "p1", models: []string{"gpt-4"}, healthy: true}
|
||||
prov2 := &mockProvider{name: "p2", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("p1", prov1)
|
||||
r.RegisterProvider("p2", prov2)
|
||||
|
||||
r.health["p1"].FailureRate = 0.3
|
||||
r.health["p2"].FailureRate = 0.1
|
||||
|
||||
selected, err := r.selectByAvailability([]string{"p1", "p2"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if selected.ProviderName() != "p2" {
|
||||
t.Errorf("expected provider with lower failure rate, got %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFallbackProviders(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov1 := &mockProvider{name: "primary", models: []string{"gpt-4"}, healthy: true}
|
||||
prov2 := &mockProvider{name: "fallback", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("primary", prov1)
|
||||
r.RegisterProvider("fallback", prov2)
|
||||
|
||||
fallbacks, err := r.GetFallbackProviders(context.Background(), "gpt-4")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fallbacks) != 1 {
|
||||
t.Errorf("expected 1 fallback, got %d", len(fallbacks))
|
||||
}
|
||||
if fallbacks[0].ProviderName() != "fallback" {
|
||||
t.Errorf("expected fallback, got %s", fallbacks[0].ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFallbackProviders_AllUnavailable(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "primary", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("primary", prov)
|
||||
|
||||
fallbacks, err := r.GetFallbackProviders(context.Background(), "gpt-4")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fallbacks) != 0 {
|
||||
t.Errorf("expected 0 fallbacks, got %d", len(fallbacks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_LatencyUpdate(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 首次记录
|
||||
r.RecordResult(context.Background(), "test", true, 100)
|
||||
if r.health["test"].LatencyMs != 100 {
|
||||
t.Errorf("expected latency 100, got %d", r.health["test"].LatencyMs)
|
||||
}
|
||||
|
||||
// 第二次记录,使用指数移动平均 (7/8 * 100 + 1/8 * 200 = 87.5 + 25 = 112.5)
|
||||
r.RecordResult(context.Background(), "test", true, 200)
|
||||
expectedLatency := int64((100*7 + 200) / 8)
|
||||
if r.health["test"].LatencyMs != expectedLatency {
|
||||
t.Errorf("expected latency %d, got %d", expectedLatency, r.health["test"].LatencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_UnknownProvider(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
// 不应该panic
|
||||
r.RecordResult(context.Background(), "unknown", true, 100)
|
||||
}
|
||||
|
||||
func TestUpdateHealth_UnknownProvider(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
// 不应该panic
|
||||
r.UpdateHealth("unknown", false)
|
||||
}
|
||||
|
||||
func TestIsProviderAvailable(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4", "gpt-3.5"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
tests := []struct {
|
||||
model string
|
||||
available bool
|
||||
}{
|
||||
{"gpt-4", true},
|
||||
{"gpt-3.5", true},
|
||||
{"claude", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := r.isProviderAvailable("test", tt.model); got != tt.available {
|
||||
t.Errorf("isProviderAvailable(%s) = %v, want %v", tt.model, got, tt.available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProviderAvailable_UnknownProvider(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
|
||||
if r.isProviderAvailable("unknown", "gpt-4") {
|
||||
t.Error("expected false for unknown provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProviderAvailable_Unhealthy(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 通过UpdateHealth标记为不可用
|
||||
r.UpdateHealth("test", false)
|
||||
|
||||
if r.isProviderAvailable("test", "gpt-4") {
|
||||
t.Error("expected false for unhealthy provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderHealth_Struct(t *testing.T) {
|
||||
health := &ProviderHealth{
|
||||
Name: "test",
|
||||
Available: true,
|
||||
LatencyMs: 50,
|
||||
FailureRate: 0.1,
|
||||
Weight: 1.0,
|
||||
LastCheckTime: time.Now(),
|
||||
}
|
||||
|
||||
if health.Name != "test" {
|
||||
t.Errorf("expected name test, got %s", health.Name)
|
||||
}
|
||||
if !health.Available {
|
||||
t.Error("expected available")
|
||||
}
|
||||
if health.LatencyMs != 50 {
|
||||
t.Errorf("expected latency 50, got %d", health.LatencyMs)
|
||||
}
|
||||
if health.FailureRate != 0.1 {
|
||||
t.Errorf("expected failure rate 0.1, got %f", health.FailureRate)
|
||||
}
|
||||
if health.Weight != 1.0 {
|
||||
t.Errorf("expected weight 1.0, got %f", health.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBalancerStrategy_Constants(t *testing.T) {
|
||||
if StrategyLatency != "latency" {
|
||||
t.Errorf("expected latency, got %s", StrategyLatency)
|
||||
}
|
||||
if StrategyRoundRobin != "round_robin" {
|
||||
t.Errorf("expected round_robin, got %s", StrategyRoundRobin)
|
||||
}
|
||||
if StrategyWeighted != "weighted" {
|
||||
t.Errorf("expected weighted, got %s", StrategyWeighted)
|
||||
}
|
||||
if StrategyAvailability != "availability" {
|
||||
t.Errorf("expected availability, got %s", StrategyAvailability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProvider_AllStrategies(t *testing.T) {
|
||||
strategies := []LoadBalancerStrategy{StrategyLatency, StrategyWeighted, StrategyAvailability}
|
||||
|
||||
for _, strategy := range strategies {
|
||||
r := NewRouter(strategy)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
selected, err := r.SelectProvider(context.Background(), "gpt-4")
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("strategy %s: unexpected error: %v", strategy, err)
|
||||
}
|
||||
if selected.ProviderName() != "test" {
|
||||
t.Errorf("strategy %s: expected provider test, got %s", strategy, selected.ProviderName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保FailureRate永远不会超过1.0
|
||||
func TestRecordResult_FailureRateCapped(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 多次失败
|
||||
for i := 0; i < 20; i++ {
|
||||
r.RecordResult(context.Background(), "test", false, 100)
|
||||
}
|
||||
|
||||
if r.health["test"].FailureRate > 1.0 {
|
||||
t.Errorf("failure rate should be capped at 1.0, got %f", r.health["test"].FailureRate)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保LatencyMs永远不会变成负数
|
||||
func TestRecordResult_LatencyNeverNegative(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov := &mockProvider{name: "test", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("test", prov)
|
||||
|
||||
// 提供负延迟
|
||||
r.RecordResult(context.Background(), "test", true, -100)
|
||||
|
||||
if r.health["test"].LatencyMs < 0 {
|
||||
t.Errorf("latency should never be negative, got %d", r.health["test"].LatencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保math.MaxInt64不会溢出
|
||||
func TestSelectByLatency_MaxInt64(t *testing.T) {
|
||||
r := NewRouter(StrategyLatency)
|
||||
prov1 := &mockProvider{name: "p1", models: []string{"gpt-4"}, healthy: true}
|
||||
prov2 := &mockProvider{name: "p2", models: []string{"gpt-4"}, healthy: true}
|
||||
r.RegisterProvider("p1", prov1)
|
||||
r.RegisterProvider("p2", prov2)
|
||||
|
||||
// p1设置为较大值,p2设置为MaxInt64
|
||||
r.health["p1"].LatencyMs = math.MaxInt64 - 1
|
||||
r.health["p2"].LatencyMs = math.MaxInt64
|
||||
|
||||
selected, err := r.selectByLatency([]string{"p1", "p2"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// p1的延迟更低,应该被选中
|
||||
if selected.ProviderName() != "p1" {
|
||||
t.Errorf("expected provider p1 (lower latency), got %s", selected.ProviderName())
|
||||
}
|
||||
}
|
||||
74
gateway/internal/router/scoring/scoring_model.go
Normal file
74
gateway/internal/router/scoring/scoring_model.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// ProviderMetrics Provider评分指标
|
||||
type ProviderMetrics struct {
|
||||
Name string
|
||||
LatencyMs int64
|
||||
Availability float64
|
||||
CostPer1KTokens float64
|
||||
QualityScore float64
|
||||
}
|
||||
|
||||
// ScoringModel 评分模型
|
||||
type ScoringModel struct {
|
||||
weights ScoreWeights
|
||||
}
|
||||
|
||||
// NewScoringModel 创建评分模型
|
||||
func NewScoringModel(weights ScoreWeights) *ScoringModel {
|
||||
return &ScoringModel{
|
||||
weights: weights,
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateScore 计算单个Provider的综合评分
|
||||
// 评分范围: 0.0 - 1.0, 越高越好
|
||||
func (m *ScoringModel) CalculateScore(provider ProviderMetrics) float64 {
|
||||
// 计算各维度得分
|
||||
|
||||
// 延迟得分: 使用指数衰减,越低越好
|
||||
// 基准延迟100ms,得分0.5;延迟0ms得分1.0
|
||||
latencyScore := math.Exp(-float64(provider.LatencyMs) / 200.0)
|
||||
|
||||
// 可用性得分: 直接使用可用性值
|
||||
availabilityScore := provider.Availability
|
||||
|
||||
// 成本得分: 使用指数衰减,越低越好
|
||||
// 基准成本$1/1K tokens,得分0.5;成本0得分1.0
|
||||
costScore := math.Exp(-provider.CostPer1KTokens)
|
||||
|
||||
// 质量得分: 直接使用质量分数
|
||||
qualityScore := provider.QualityScore
|
||||
|
||||
// 综合评分 = 延迟权重*延迟得分 + 可用性权重*可用性得分 + 成本权重*成本得分 + 质量权重*质量得分
|
||||
totalScore := m.weights.LatencyWeight*latencyScore +
|
||||
m.weights.AvailabilityWeight*availabilityScore +
|
||||
m.weights.CostWeight*costScore +
|
||||
m.weights.QualityWeight*qualityScore
|
||||
|
||||
return math.Max(0, math.Min(1, totalScore))
|
||||
}
|
||||
|
||||
// SelectBestProvider 从候选列表中选择最佳Provider
|
||||
func (m *ScoringModel) SelectBestProvider(providers []ProviderMetrics) *ProviderMetrics {
|
||||
if len(providers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
best := &providers[0]
|
||||
bestScore := m.CalculateScore(*best)
|
||||
|
||||
for i := 1; i < len(providers); i++ {
|
||||
score := m.CalculateScore(providers[i])
|
||||
if score > bestScore {
|
||||
best = &providers[i]
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
149
gateway/internal/router/scoring/scoring_model_test.go
Normal file
149
gateway/internal/router/scoring/scoring_model_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestScoringModel_CalculateScore_Latency(t *testing.T) {
|
||||
// 低延迟应该得高分
|
||||
model := NewScoringModel(DefaultWeights)
|
||||
|
||||
// Provider A: 延迟100ms
|
||||
providerA := ProviderMetrics{
|
||||
Name: "ProviderA",
|
||||
LatencyMs: 100,
|
||||
}
|
||||
|
||||
// Provider B: 延迟200ms
|
||||
providerB := ProviderMetrics{
|
||||
Name: "ProviderB",
|
||||
LatencyMs: 200,
|
||||
}
|
||||
|
||||
scoreA := model.CalculateScore(providerA)
|
||||
scoreB := model.CalculateScore(providerB)
|
||||
|
||||
// 延迟低的应该分数高
|
||||
assert.Greater(t, scoreA, scoreB, "Lower latency should result in higher score")
|
||||
}
|
||||
|
||||
func TestScoringModel_CalculateScore_Availability(t *testing.T) {
|
||||
// 高可用应该得高分
|
||||
model := NewScoringModel(DefaultWeights)
|
||||
|
||||
// Provider A: 可用性 99%
|
||||
providerA := ProviderMetrics{
|
||||
Name: "ProviderA",
|
||||
Availability: 0.99,
|
||||
}
|
||||
|
||||
// Provider B: 可用性 90%
|
||||
providerB := ProviderMetrics{
|
||||
Name: "ProviderB",
|
||||
Availability: 0.90,
|
||||
}
|
||||
|
||||
scoreA := model.CalculateScore(providerA)
|
||||
scoreB := model.CalculateScore(providerB)
|
||||
|
||||
// 可用性高的应该分数高
|
||||
assert.Greater(t, scoreA, scoreB, "Higher availability should result in higher score")
|
||||
}
|
||||
|
||||
func TestScoringModel_CalculateScore_Cost(t *testing.T) {
|
||||
// 低成本应该得高分
|
||||
model := NewScoringModel(DefaultWeights)
|
||||
|
||||
// Provider A: 成本 $0.5/1K tokens
|
||||
providerA := ProviderMetrics{
|
||||
Name: "ProviderA",
|
||||
CostPer1KTokens: 0.5,
|
||||
}
|
||||
|
||||
// Provider B: 成本 $1.0/1K tokens
|
||||
providerB := ProviderMetrics{
|
||||
Name: "ProviderB",
|
||||
CostPer1KTokens: 1.0,
|
||||
}
|
||||
|
||||
scoreA := model.CalculateScore(providerA)
|
||||
scoreB := model.CalculateScore(providerB)
|
||||
|
||||
// 成本低的应该分数高
|
||||
assert.Greater(t, scoreA, scoreB, "Lower cost should result in higher score")
|
||||
}
|
||||
|
||||
func TestScoringModel_CalculateScore_Quality(t *testing.T) {
|
||||
// 高质量应该得高分
|
||||
model := NewScoringModel(DefaultWeights)
|
||||
|
||||
// Provider A: 质量 0.95
|
||||
providerA := ProviderMetrics{
|
||||
Name: "ProviderA",
|
||||
QualityScore: 0.95,
|
||||
}
|
||||
|
||||
// Provider B: 质量 0.80
|
||||
providerB := ProviderMetrics{
|
||||
Name: "ProviderB",
|
||||
QualityScore: 0.80,
|
||||
}
|
||||
|
||||
scoreA := model.CalculateScore(providerA)
|
||||
scoreB := model.CalculateScore(providerB)
|
||||
|
||||
// 质量高的应该分数高
|
||||
assert.Greater(t, scoreA, scoreB, "Higher quality should result in higher score")
|
||||
}
|
||||
|
||||
func TestScoringModel_CalculateScore_Combined(t *testing.T) {
|
||||
// 综合评分正确
|
||||
model := NewScoringModel(DefaultWeights)
|
||||
|
||||
// 完美provider: 延迟0ms, 可用性100%, 成本0$/1K, 质量1.0
|
||||
perfect := ProviderMetrics{
|
||||
Name: "Perfect",
|
||||
LatencyMs: 0,
|
||||
Availability: 1.0,
|
||||
CostPer1KTokens: 0,
|
||||
QualityScore: 1.0,
|
||||
}
|
||||
|
||||
// 最差provider: 延迟1000ms, 可用性0%, 成本10$/1K, 质量0
|
||||
worst := ProviderMetrics{
|
||||
Name: "Worst",
|
||||
LatencyMs: 1000,
|
||||
Availability: 0.0,
|
||||
CostPer1KTokens: 10.0,
|
||||
QualityScore: 0.0,
|
||||
}
|
||||
|
||||
scorePerfect := model.CalculateScore(perfect)
|
||||
scoreWorst := model.CalculateScore(worst)
|
||||
|
||||
// 完美的应该分数高
|
||||
assert.Greater(t, scorePerfect, scoreWorst, "Perfect provider should score higher than worst")
|
||||
|
||||
// 完美分数应该在合理范围内 (接近1.0)
|
||||
assert.LessOrEqual(t, scorePerfect, 1.0, "Perfect score should be <= 1.0")
|
||||
assert.Greater(t, scorePerfect, 0.9, "Perfect score should be > 0.9")
|
||||
}
|
||||
|
||||
func TestScoringModel_SelectBestProvider(t *testing.T) {
|
||||
// 选择最佳provider
|
||||
model := NewScoringModel(DefaultWeights)
|
||||
|
||||
providers := []ProviderMetrics{
|
||||
{Name: "ProviderA", LatencyMs: 100, Availability: 0.99, CostPer1KTokens: 0.5, QualityScore: 0.9},
|
||||
{Name: "ProviderB", LatencyMs: 50, Availability: 0.95, CostPer1KTokens: 0.8, QualityScore: 0.85},
|
||||
{Name: "ProviderC", LatencyMs: 200, Availability: 0.99, CostPer1KTokens: 0.3, QualityScore: 0.8},
|
||||
}
|
||||
|
||||
best := model.SelectBestProvider(providers)
|
||||
|
||||
// 验证返回了provider
|
||||
assert.NotNil(t, best, "Should return a provider")
|
||||
assert.Equal(t, "ProviderB", best.Name, "ProviderB should be selected (low latency with good balance)")
|
||||
}
|
||||
25
gateway/internal/router/scoring/weights.go
Normal file
25
gateway/internal/router/scoring/weights.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package scoring
|
||||
|
||||
// ScoreWeights 评分权重配置
|
||||
type ScoreWeights struct {
|
||||
// LatencyWeight 延迟权重 (40%)
|
||||
LatencyWeight float64
|
||||
// AvailabilityWeight 可用性权重 (30%)
|
||||
AvailabilityWeight float64
|
||||
// CostWeight 成本权重 (20%)
|
||||
CostWeight float64
|
||||
// QualityWeight 质量权重 (10%)
|
||||
QualityWeight float64
|
||||
}
|
||||
|
||||
// DefaultWeights 默认权重配置
|
||||
// LatencyWeight = 0.4 (40%)
|
||||
// AvailabilityWeight = 0.3 (30%)
|
||||
// CostWeight = 0.2 (20%)
|
||||
// QualityWeight = 0.1 (10%)
|
||||
var DefaultWeights = ScoreWeights{
|
||||
LatencyWeight: 0.4,
|
||||
AvailabilityWeight: 0.3,
|
||||
CostWeight: 0.2,
|
||||
QualityWeight: 0.1,
|
||||
}
|
||||
30
gateway/internal/router/scoring/weights_test.go
Normal file
30
gateway/internal/router/scoring/weights_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestScoreWeights_DefaultValues(t *testing.T) {
|
||||
// 验证默认权重
|
||||
// LatencyWeight = 0.4 (40%)
|
||||
// AvailabilityWeight = 0.3 (30%)
|
||||
// CostWeight = 0.2 (20%)
|
||||
// QualityWeight = 0.1 (10%)
|
||||
|
||||
assert.Equal(t, 0.4, DefaultWeights.LatencyWeight, "LatencyWeight should be 0.4 (40%%)")
|
||||
assert.Equal(t, 0.3, DefaultWeights.AvailabilityWeight, "AvailabilityWeight should be 0.3 (30%%)")
|
||||
assert.Equal(t, 0.2, DefaultWeights.CostWeight, "CostWeight should be 0.2 (20%%)")
|
||||
assert.Equal(t, 0.1, DefaultWeights.QualityWeight, "QualityWeight should be 0.1 (10%%)")
|
||||
}
|
||||
|
||||
func TestScoreWeights_Sum(t *testing.T) {
|
||||
// 验证权重总和为1.0
|
||||
total := DefaultWeights.LatencyWeight +
|
||||
DefaultWeights.AvailabilityWeight +
|
||||
DefaultWeights.CostWeight +
|
||||
DefaultWeights.QualityWeight
|
||||
|
||||
assert.InDelta(t, 1.0, total, 0.001, "Weights sum should be 1.0")
|
||||
}
|
||||
71
gateway/internal/router/strategy/ab_strategy.go
Normal file
71
gateway/internal/router/strategy/ab_strategy.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ABStrategy A/B测试策略
|
||||
type ABStrategy struct {
|
||||
controlStrategy *RoutingStrategyTemplate
|
||||
experimentStrategy *RoutingStrategyTemplate
|
||||
trafficSplit int // 实验组流量百分比 (0-100)
|
||||
bucketKey string // 分桶key
|
||||
experimentID string
|
||||
startTime *time.Time
|
||||
endTime *time.Time
|
||||
}
|
||||
|
||||
// NewABStrategy 创建A/B测试策略
|
||||
func NewABStrategy(control, experiment *RoutingStrategyTemplate, split int, bucketKey string) *ABStrategy {
|
||||
return &ABStrategy{
|
||||
controlStrategy: control,
|
||||
experimentStrategy: experiment,
|
||||
trafficSplit: split,
|
||||
bucketKey: bucketKey,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldApplyToRequest 判断请求是否应该使用实验组策略
|
||||
func (a *ABStrategy) ShouldApplyToRequest(req *RoutingRequest) bool {
|
||||
// 检查时间范围
|
||||
now := time.Now()
|
||||
if a.startTime != nil && now.Before(*a.startTime) {
|
||||
return false
|
||||
}
|
||||
if a.endTime != nil && now.After(*a.endTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 一致性哈希分桶
|
||||
bucket := a.hashString(fmt.Sprintf("%s:%s", a.bucketKey, req.UserID)) % 100
|
||||
return bucket < a.trafficSplit
|
||||
}
|
||||
|
||||
// hashString 计算字符串哈希值 (用于一致性分桶)
|
||||
func (a *ABStrategy) hashString(s string) int {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(s))
|
||||
return int(h.Sum32())
|
||||
}
|
||||
|
||||
// GetControlStrategy 获取对照组策略
|
||||
func (a *ABStrategy) GetControlStrategy() *RoutingStrategyTemplate {
|
||||
return a.controlStrategy
|
||||
}
|
||||
|
||||
// GetExperimentStrategy 获取实验组策略
|
||||
func (a *ABStrategy) GetExperimentStrategy() *RoutingStrategyTemplate {
|
||||
return a.experimentStrategy
|
||||
}
|
||||
|
||||
// RoutingStrategyTemplate 路由策略模板
|
||||
type RoutingStrategyTemplate struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Priority int
|
||||
Enabled bool
|
||||
Description string
|
||||
}
|
||||
161
gateway/internal/router/strategy/ab_strategy_test.go
Normal file
161
gateway/internal/router/strategy/ab_strategy_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestABStrategy_TrafficSplit 测试A/B测试流量分配
|
||||
func TestABStrategy_TrafficSplit(t *testing.T) {
|
||||
ab := &ABStrategy{
|
||||
controlStrategy: &RoutingStrategyTemplate{ID: "control"},
|
||||
experimentStrategy: &RoutingStrategyTemplate{ID: "experiment"},
|
||||
trafficSplit: 20, // 20%实验组
|
||||
bucketKey: "user_id",
|
||||
}
|
||||
|
||||
// 验证流量分配
|
||||
// 一致性哈希:同一user_id始终分配到同一组
|
||||
controlCount := 0
|
||||
experimentCount := 0
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('0' + i))
|
||||
isExperiment := ab.ShouldApplyToRequest(&RoutingRequest{UserID: userID})
|
||||
|
||||
if isExperiment {
|
||||
experimentCount++
|
||||
} else {
|
||||
controlCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 验证一致性:同一user_id应该始终在同一组
|
||||
for i := 0; i < 10; i++ {
|
||||
userID := "test_user_123"
|
||||
first := ab.ShouldApplyToRequest(&RoutingRequest{UserID: userID})
|
||||
for j := 0; j < 10; j++ {
|
||||
second := ab.ShouldApplyToRequest(&RoutingRequest{UserID: userID})
|
||||
assert.Equal(t, first, second, "Same user_id should always be in same group")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证分配比例大约是80:20
|
||||
assert.InDelta(t, 80, controlCount, 15, "Control should be around 80%%")
|
||||
assert.InDelta(t, 20, experimentCount, 15, "Experiment should be around 20%%")
|
||||
}
|
||||
|
||||
// TestRollout_Percentage 测试灰度发布百分比递增
|
||||
func TestRollout_Percentage(t *testing.T) {
|
||||
rollout := &RolloutStrategy{
|
||||
percentage: 10,
|
||||
bucketKey: "user_id",
|
||||
}
|
||||
|
||||
// 统计10%时的用户数
|
||||
count10 := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('0' + i))
|
||||
if rollout.ShouldApply(&RoutingRequest{UserID: userID}) {
|
||||
count10++
|
||||
}
|
||||
}
|
||||
assert.InDelta(t, 10, count10, 5, "10%% rollout should have around 10 users")
|
||||
|
||||
// 增加百分比到20%
|
||||
rollout.SetPercentage(20)
|
||||
|
||||
// 统计20%时的用户数
|
||||
count20 := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('0' + i))
|
||||
if rollout.ShouldApply(&RoutingRequest{UserID: userID}) {
|
||||
count20++
|
||||
}
|
||||
}
|
||||
assert.InDelta(t, 20, count20, 5, "20%% rollout should have around 20 users")
|
||||
|
||||
// 增加百分比到50%
|
||||
rollout.SetPercentage(50)
|
||||
|
||||
// 统计50%时的用户数
|
||||
count50 := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('0' + i))
|
||||
if rollout.ShouldApply(&RoutingRequest{UserID: userID}) {
|
||||
count50++
|
||||
}
|
||||
}
|
||||
assert.InDelta(t, 50, count50, 10, "50%% rollout should have around 50 users")
|
||||
|
||||
// 增加百分比到100%
|
||||
rollout.SetPercentage(100)
|
||||
|
||||
// 验证100%时所有用户都在
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('0' + i))
|
||||
assert.True(t, rollout.ShouldApply(&RoutingRequest{UserID: userID}), "All users should be in 100% rollout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollout_Consistency 测试灰度发布一致性
|
||||
func TestRollout_Consistency(t *testing.T) {
|
||||
rollout := &RolloutStrategy{
|
||||
percentage: 30,
|
||||
bucketKey: "user_id",
|
||||
}
|
||||
|
||||
// 同一用户应该始终被同样对待
|
||||
userID := "consistent_user"
|
||||
firstResult := rollout.ShouldApply(&RoutingRequest{UserID: userID})
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
result := rollout.ShouldApply(&RoutingRequest{UserID: userID})
|
||||
assert.Equal(t, firstResult, result, "Same user should always have same result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollout_PercentageIncrease 测试百分比递增
|
||||
func TestRollout_PercentageIncrease(t *testing.T) {
|
||||
rollout := &RolloutStrategy{
|
||||
percentage: 10,
|
||||
bucketKey: "user_id",
|
||||
}
|
||||
|
||||
// 收集10%时的用户
|
||||
var in10Percent []string
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('a' + i))
|
||||
if rollout.ShouldApply(&RoutingRequest{UserID: userID}) {
|
||||
in10Percent = append(in10Percent, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 增加百分比到50%
|
||||
rollout.SetPercentage(50)
|
||||
|
||||
// 收集50%时的用户
|
||||
var in50Percent []string
|
||||
for i := 0; i < 100; i++ {
|
||||
userID := string(rune('a' + i))
|
||||
if rollout.ShouldApply(&RoutingRequest{UserID: userID}) {
|
||||
in50Percent = append(in50Percent, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 50%的用户应该包含10%的用户(一致性)
|
||||
for _, userID := range in10Percent {
|
||||
found := false
|
||||
for _, id := range in50Percent {
|
||||
if userID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "10%% users should be included in 50%% rollout")
|
||||
}
|
||||
|
||||
// 50%应该包含更多用户
|
||||
assert.Greater(t, len(in50Percent), len(in10Percent), "50%% should have more users than 10%%")
|
||||
}
|
||||
189
gateway/internal/router/strategy/cost_aware.go
Normal file
189
gateway/internal/router/strategy/cost_aware.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/internal/router/scoring"
|
||||
gwerror "lijiaoqiao/gateway/pkg/error"
|
||||
)
|
||||
|
||||
// ErrNoQualifiedProvider 没有符合条件的Provider
|
||||
var ErrNoQualifiedProvider = errors.New("no qualified provider available")
|
||||
|
||||
// CostAwareTemplate 成本感知策略模板
|
||||
// 综合考虑成本、质量、延迟进行权衡
|
||||
type CostAwareTemplate struct {
|
||||
name string
|
||||
maxCostPer1KTokens float64
|
||||
maxLatencyMs int64
|
||||
minQualityScore float64
|
||||
providers map[string]adapter.ProviderAdapter
|
||||
scoringModel *scoring.ScoringModel
|
||||
}
|
||||
|
||||
// CostAwareParams 成本感知参数
|
||||
type CostAwareParams struct {
|
||||
MaxCostPer1KTokens float64
|
||||
MaxLatencyMs int64
|
||||
MinQualityScore float64
|
||||
}
|
||||
|
||||
// NewCostAwareTemplate 创建成本感知策略模板
|
||||
func NewCostAwareTemplate(name string, params CostAwareParams) *CostAwareTemplate {
|
||||
return &CostAwareTemplate{
|
||||
name: name,
|
||||
maxCostPer1KTokens: params.MaxCostPer1KTokens,
|
||||
maxLatencyMs: params.MaxLatencyMs,
|
||||
minQualityScore: params.MinQualityScore,
|
||||
providers: make(map[string]adapter.ProviderAdapter),
|
||||
scoringModel: scoring.NewScoringModel(scoring.DefaultWeights),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterProvider 注册Provider
|
||||
func (t *CostAwareTemplate) RegisterProvider(name string, provider adapter.ProviderAdapter) {
|
||||
t.providers[name] = provider
|
||||
}
|
||||
|
||||
// Name 获取策略名称
|
||||
func (t *CostAwareTemplate) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
// Type 获取策略类型
|
||||
func (t *CostAwareTemplate) Type() string {
|
||||
return "cost_aware"
|
||||
}
|
||||
|
||||
// SelectProvider 选择最佳平衡的Provider
|
||||
func (t *CostAwareTemplate) SelectProvider(ctx context.Context, req *RoutingRequest) (*RoutingDecision, error) {
|
||||
if len(t.providers) == 0 {
|
||||
return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no provider registered")
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
name string
|
||||
cost float64
|
||||
quality float64
|
||||
latency int64
|
||||
score float64
|
||||
}
|
||||
|
||||
var candidates []candidate
|
||||
maxCost := t.maxCostPer1KTokens
|
||||
if req.MaxCost > 0 && req.MaxCost < maxCost {
|
||||
maxCost = req.MaxCost
|
||||
}
|
||||
maxLatency := t.maxLatencyMs
|
||||
if req.MaxLatency > 0 && req.MaxLatency < maxLatency {
|
||||
maxLatency = req.MaxLatency
|
||||
}
|
||||
minQuality := t.minQualityScore
|
||||
if req.MinQuality > 0 && req.MinQuality > minQuality {
|
||||
minQuality = req.MinQuality
|
||||
}
|
||||
|
||||
for name, provider := range t.providers {
|
||||
// 检查provider是否支持该模型
|
||||
supported := false
|
||||
for _, m := range provider.SupportedModels() {
|
||||
if m == req.Model || m == "*" {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查健康状态
|
||||
if !provider.HealthCheck(ctx) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取provider指标
|
||||
cost := t.getProviderCost(provider)
|
||||
quality := t.getProviderQuality(provider)
|
||||
latency := t.getProviderLatency(provider)
|
||||
|
||||
// 过滤不满足基本条件的provider
|
||||
if cost > maxCost || latency > maxLatency || quality < minQuality {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算综合评分
|
||||
metrics := scoring.ProviderMetrics{
|
||||
Name: name,
|
||||
LatencyMs: latency,
|
||||
Availability: 1.0, // 假设可用
|
||||
CostPer1KTokens: cost,
|
||||
QualityScore: quality,
|
||||
}
|
||||
score := t.scoringModel.CalculateScore(metrics)
|
||||
|
||||
candidates = append(candidates, candidate{
|
||||
name: name,
|
||||
cost: cost,
|
||||
quality: quality,
|
||||
latency: latency,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, ErrNoQualifiedProvider
|
||||
}
|
||||
|
||||
// 选择评分最高的provider
|
||||
best := &candidates[0]
|
||||
for i := 1; i < len(candidates); i++ {
|
||||
if candidates[i].score > best.score {
|
||||
best = &candidates[i]
|
||||
}
|
||||
}
|
||||
|
||||
return &RoutingDecision{
|
||||
Provider: best.name,
|
||||
Strategy: t.Type(),
|
||||
CostPer1KTokens: best.cost,
|
||||
EstimatedLatency: best.latency,
|
||||
QualityScore: best.quality,
|
||||
TakeoverMark: true, // M-008: 标记为接管
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getProviderCost 获取Provider的成本
|
||||
func (t *CostAwareTemplate) getProviderCost(provider adapter.ProviderAdapter) float64 {
|
||||
if cp, ok := provider.(CostAwareProvider); ok {
|
||||
return cp.GetCostPer1KTokens()
|
||||
}
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// getProviderQuality 获取Provider的质量分数
|
||||
func (t *CostAwareTemplate) getProviderQuality(provider adapter.ProviderAdapter) float64 {
|
||||
if qp, ok := provider.(QualityProvider); ok {
|
||||
return qp.GetQualityScore()
|
||||
}
|
||||
return 0.8 // 默认质量分数
|
||||
}
|
||||
|
||||
// getProviderLatency 获取Provider的延迟
|
||||
func (t *CostAwareTemplate) getProviderLatency(provider adapter.ProviderAdapter) int64 {
|
||||
if lp, ok := provider.(LatencyProvider); ok {
|
||||
return lp.GetLatencyMs()
|
||||
}
|
||||
return 100 // 默认延迟100ms
|
||||
}
|
||||
|
||||
// QualityProvider 质量感知Provider接口
|
||||
type QualityProvider interface {
|
||||
GetQualityScore() float64
|
||||
}
|
||||
|
||||
// LatencyProvider 延迟感知Provider接口
|
||||
type LatencyProvider interface {
|
||||
GetLatencyMs() int64
|
||||
}
|
||||
108
gateway/internal/router/strategy/cost_aware_test.go
Normal file
108
gateway/internal/router/strategy/cost_aware_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestCostAwareStrategy_Balance 测试成本感知策略的平衡选择
|
||||
func TestCostAwareStrategy_Balance(t *testing.T) {
|
||||
template := NewCostAwareTemplate("CostAware", CostAwareParams{
|
||||
MaxCostPer1KTokens: 1.0,
|
||||
MaxLatencyMs: 500,
|
||||
MinQualityScore: 0.7,
|
||||
})
|
||||
|
||||
// 注册多个providers
|
||||
// ProviderA: 低成本, 低质量
|
||||
template.providers["ProviderA"] = &MockProvider{
|
||||
name: "ProviderA",
|
||||
costPer1KTokens: 0.2,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
qualityScore: 0.6, // 质量不达标
|
||||
latencyMs: 100,
|
||||
}
|
||||
|
||||
// ProviderB: 中成本, 高质量, 低延迟
|
||||
template.providers["ProviderB"] = &MockProvider{
|
||||
name: "ProviderB",
|
||||
costPer1KTokens: 0.5,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
qualityScore: 0.9,
|
||||
latencyMs: 150,
|
||||
}
|
||||
|
||||
// ProviderC: 高成本, 高质量, 高延迟
|
||||
template.providers["ProviderC"] = &MockProvider{
|
||||
name: "ProviderC",
|
||||
costPer1KTokens: 0.9,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
qualityScore: 0.95,
|
||||
latencyMs: 400,
|
||||
}
|
||||
|
||||
req := &RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
MaxCost: 1.0,
|
||||
MaxLatency: 500,
|
||||
MinQuality: 0.7,
|
||||
}
|
||||
|
||||
decision, err := template.SelectProvider(context.Background(), req)
|
||||
|
||||
// 验证选择逻辑
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, decision)
|
||||
|
||||
// ProviderA因质量不达标应被排除
|
||||
// ProviderB应在成本/质量/延迟权衡中胜出
|
||||
assert.Equal(t, "ProviderB", decision.Provider, "Should select balanced provider")
|
||||
assert.GreaterOrEqual(t, decision.QualityScore, 0.7, "Quality should meet minimum")
|
||||
assert.LessOrEqual(t, decision.CostPer1KTokens, 1.0, "Cost should be within budget")
|
||||
assert.LessOrEqual(t, decision.EstimatedLatency, int64(500), "Latency should be within limit")
|
||||
}
|
||||
|
||||
// TestCostAwareStrategy_QualityThreshold 测试质量阈值过滤
|
||||
func TestCostAwareStrategy_QualityThreshold(t *testing.T) {
|
||||
template := NewCostAwareTemplate("CostAware", CostAwareParams{
|
||||
MaxCostPer1KTokens: 1.0,
|
||||
MaxLatencyMs: 1000,
|
||||
MinQualityScore: 0.9, // 高质量要求
|
||||
})
|
||||
|
||||
// 所有provider质量都不达标
|
||||
template.providers["ProviderA"] = &MockProvider{
|
||||
name: "ProviderA",
|
||||
costPer1KTokens: 0.3,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
qualityScore: 0.7,
|
||||
latencyMs: 100,
|
||||
}
|
||||
template.providers["ProviderB"] = &MockProvider{
|
||||
name: "ProviderB",
|
||||
costPer1KTokens: 0.4,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
qualityScore: 0.8,
|
||||
latencyMs: 150,
|
||||
}
|
||||
|
||||
req := &RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
MinQuality: 0.9,
|
||||
}
|
||||
|
||||
decision, err := template.SelectProvider(context.Background(), req)
|
||||
|
||||
// 应该返回错误,因为没有满足质量要求的provider
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, decision)
|
||||
}
|
||||
132
gateway/internal/router/strategy/cost_based.go
Normal file
132
gateway/internal/router/strategy/cost_based.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
gwerror "lijiaoqiao/gateway/pkg/error"
|
||||
)
|
||||
|
||||
// ErrNoAffordableProvider 没有可负担的Provider
|
||||
var ErrNoAffordableProvider = errors.New("no affordable provider available")
|
||||
|
||||
// CostBasedTemplate 成本优先策略模板
|
||||
// 选择成本最低的provider
|
||||
type CostBasedTemplate struct {
|
||||
name string
|
||||
maxCostPer1KTokens float64
|
||||
providers map[string]adapter.ProviderAdapter
|
||||
}
|
||||
|
||||
// CostParams 成本参数
|
||||
type CostParams struct {
|
||||
// 最大成本 ($/1K tokens)
|
||||
MaxCostPer1KTokens float64
|
||||
}
|
||||
|
||||
// NewCostBasedTemplate 创建成本优先策略模板
|
||||
func NewCostBasedTemplate(name string, params CostParams) *CostBasedTemplate {
|
||||
return &CostBasedTemplate{
|
||||
name: name,
|
||||
maxCostPer1KTokens: params.MaxCostPer1KTokens,
|
||||
providers: make(map[string]adapter.ProviderAdapter),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterProvider 注册Provider
|
||||
func (t *CostBasedTemplate) RegisterProvider(name string, provider adapter.ProviderAdapter) {
|
||||
t.providers[name] = provider
|
||||
}
|
||||
|
||||
// Name 获取策略名称
|
||||
func (t *CostBasedTemplate) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
// Type 获取策略类型
|
||||
func (t *CostBasedTemplate) Type() string {
|
||||
return "cost_based"
|
||||
}
|
||||
|
||||
// SelectProvider 选择成本最低的Provider
|
||||
func (t *CostBasedTemplate) SelectProvider(ctx context.Context, req *RoutingRequest) (*RoutingDecision, error) {
|
||||
if len(t.providers) == 0 {
|
||||
return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no provider registered")
|
||||
}
|
||||
|
||||
// 收集所有可用provider的候选列表
|
||||
type candidate struct {
|
||||
name string
|
||||
cost float64
|
||||
}
|
||||
var candidates []candidate
|
||||
|
||||
for name, provider := range t.providers {
|
||||
// 检查provider是否支持该模型
|
||||
supported := false
|
||||
for _, m := range provider.SupportedModels() {
|
||||
if m == req.Model || m == "*" {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查健康状态
|
||||
if !provider.HealthCheck(ctx) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取成本信息 (实际实现需要从provider获取)
|
||||
// 这里暂时设置为模拟值
|
||||
cost := t.getProviderCost(provider)
|
||||
candidates = append(candidates, candidate{name: name, cost: cost})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, gwerror.NewGatewayError(gwerror.ROUTER_NO_PROVIDER_AVAILABLE, "no available provider for model: "+req.Model)
|
||||
}
|
||||
|
||||
// 按成本排序
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].cost < candidates[j].cost
|
||||
})
|
||||
|
||||
// 选择成本最低且在预算内的provider
|
||||
maxCost := t.maxCostPer1KTokens
|
||||
if req.MaxCost > 0 && req.MaxCost < maxCost {
|
||||
maxCost = req.MaxCost
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.cost <= maxCost {
|
||||
return &RoutingDecision{
|
||||
Provider: c.name,
|
||||
Strategy: t.Type(),
|
||||
CostPer1KTokens: c.cost,
|
||||
TakeoverMark: true, // M-008: 标记为接管
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrNoAffordableProvider
|
||||
}
|
||||
|
||||
// CostAwareProvider 成本感知Provider接口
|
||||
type CostAwareProvider interface {
|
||||
GetCostPer1KTokens() float64
|
||||
}
|
||||
|
||||
// getProviderCost 获取Provider的成本
|
||||
func (t *CostBasedTemplate) getProviderCost(provider adapter.ProviderAdapter) float64 {
|
||||
// 尝试类型断言获取成本
|
||||
if cp, ok := provider.(CostAwareProvider); ok {
|
||||
return cp.GetCostPer1KTokens()
|
||||
}
|
||||
// 默认返回0.5,实际应从provider元数据获取
|
||||
return 0.5
|
||||
}
|
||||
142
gateway/internal/router/strategy/cost_based_test.go
Normal file
142
gateway/internal/router/strategy/cost_based_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
)
|
||||
|
||||
// TestCostBasedStrategy_SelectProvider 测试成本优先策略选择Provider
|
||||
func TestCostBasedStrategy_SelectProvider(t *testing.T) {
|
||||
template := &CostBasedTemplate{
|
||||
name: "CostBased",
|
||||
maxCostPer1KTokens: 1.0,
|
||||
providers: make(map[string]adapter.ProviderAdapter),
|
||||
}
|
||||
|
||||
// 注册mock providers
|
||||
template.providers["ProviderA"] = &MockProvider{
|
||||
name: "ProviderA",
|
||||
costPer1KTokens: 0.5,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
}
|
||||
template.providers["ProviderB"] = &MockProvider{
|
||||
name: "ProviderB",
|
||||
costPer1KTokens: 0.3, // 最低成本
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
}
|
||||
template.providers["ProviderC"] = &MockProvider{
|
||||
name: "ProviderC",
|
||||
costPer1KTokens: 0.8,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
}
|
||||
|
||||
req := &RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
MaxCost: 1.0,
|
||||
}
|
||||
|
||||
decision, err := template.SelectProvider(context.Background(), req)
|
||||
|
||||
// 验证选择了最低成本的Provider
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, decision)
|
||||
assert.Equal(t, "ProviderB", decision.Provider, "Should select lowest cost provider")
|
||||
assert.LessOrEqual(t, decision.CostPer1KTokens, 1.0, "Cost should be within budget")
|
||||
}
|
||||
|
||||
func TestCostBasedStrategy_Fallback(t *testing.T) {
|
||||
// 成本超出阈值时fallback
|
||||
template := &CostBasedTemplate{
|
||||
name: "CostBased",
|
||||
maxCostPer1KTokens: 0.5, // 设置低成本上限
|
||||
providers: make(map[string]adapter.ProviderAdapter),
|
||||
}
|
||||
|
||||
// 注册成本较高的providers
|
||||
template.providers["ProviderA"] = &MockProvider{
|
||||
name: "ProviderA",
|
||||
costPer1KTokens: 0.8,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
}
|
||||
template.providers["ProviderB"] = &MockProvider{
|
||||
name: "ProviderB",
|
||||
costPer1KTokens: 1.0,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
}
|
||||
|
||||
req := &RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
MaxCost: 0.5,
|
||||
}
|
||||
|
||||
decision, err := template.SelectProvider(context.Background(), req)
|
||||
|
||||
// 应该返回错误
|
||||
assert.Error(t, err, "Should return error when no affordable provider")
|
||||
assert.Nil(t, decision, "Should not return decision when cost exceeds threshold")
|
||||
assert.Equal(t, ErrNoAffordableProvider, err, "Should return ErrNoAffordableProvider")
|
||||
}
|
||||
|
||||
// MockProvider 用于测试的Mock Provider
|
||||
type MockProvider struct {
|
||||
name string
|
||||
costPer1KTokens float64
|
||||
qualityScore float64
|
||||
latencyMs int64
|
||||
available bool
|
||||
models []string
|
||||
}
|
||||
|
||||
func (m *MockProvider) ChatCompletion(ctx context.Context, model string, messages []adapter.Message, options adapter.CompletionOptions) (*adapter.CompletionResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) ChatCompletionStream(ctx context.Context, model string, messages []adapter.Message, options adapter.CompletionOptions) (<-chan *adapter.StreamChunk, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetUsage(response *adapter.CompletionResponse) adapter.Usage {
|
||||
return adapter.Usage{}
|
||||
}
|
||||
|
||||
func (m *MockProvider) MapError(err error) adapter.ProviderError {
|
||||
return adapter.ProviderError{}
|
||||
}
|
||||
|
||||
func (m *MockProvider) HealthCheck(ctx context.Context) bool {
|
||||
return m.available
|
||||
}
|
||||
|
||||
func (m *MockProvider) ProviderName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MockProvider) SupportedModels() []string {
|
||||
return m.models
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetCostPer1KTokens() float64 {
|
||||
return m.costPer1KTokens
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetQualityScore() float64 {
|
||||
return m.qualityScore
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetLatencyMs() int64 {
|
||||
return m.latencyMs
|
||||
}
|
||||
|
||||
// Verify MockProvider implements adapter.ProviderAdapter
|
||||
var _ adapter.ProviderAdapter = (*MockProvider)(nil)
|
||||
78
gateway/internal/router/strategy/rollout.go
Normal file
78
gateway/internal/router/strategy/rollout.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RolloutStrategy 灰度发布策略
|
||||
type RolloutStrategy struct {
|
||||
percentage int // 当前灰度百分比 (0-100)
|
||||
bucketKey string // 分桶key
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRolloutStrategy 创建灰度发布策略
|
||||
func NewRolloutStrategy(percentage int, bucketKey string) *RolloutStrategy {
|
||||
return &RolloutStrategy{
|
||||
percentage: percentage,
|
||||
bucketKey: bucketKey,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPercentage 设置灰度百分比
|
||||
func (r *RolloutStrategy) SetPercentage(percentage int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if percentage < 0 {
|
||||
percentage = 0
|
||||
}
|
||||
if percentage > 100 {
|
||||
percentage = 100
|
||||
}
|
||||
r.percentage = percentage
|
||||
}
|
||||
|
||||
// GetPercentage 获取当前灰度百分比
|
||||
func (r *RolloutStrategy) GetPercentage() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.percentage
|
||||
}
|
||||
|
||||
// ShouldApply 判断请求是否应该在灰度范围内
|
||||
func (r *RolloutStrategy) ShouldApply(req *RoutingRequest) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if r.percentage >= 100 {
|
||||
return true
|
||||
}
|
||||
if r.percentage <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 一致性哈希分桶
|
||||
bucket := r.hashString(fmt.Sprintf("%s:%s", r.bucketKey, req.UserID)) % 100
|
||||
return bucket < r.percentage
|
||||
}
|
||||
|
||||
// hashString 计算字符串哈希值 (用于一致性分桶)
|
||||
func (r *RolloutStrategy) hashString(s string) int {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(s))
|
||||
return int(h.Sum32())
|
||||
}
|
||||
|
||||
// IncrementPercentage 增加灰度百分比
|
||||
func (r *RolloutStrategy) IncrementPercentage(delta int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.percentage += delta
|
||||
if r.percentage > 100 {
|
||||
r.percentage = 100
|
||||
}
|
||||
}
|
||||
65
gateway/internal/router/strategy/strategy_test.go
Normal file
65
gateway/internal/router/strategy/strategy_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
)
|
||||
|
||||
// TestStrategyTemplate_Interface 验证策略模板接口
|
||||
func TestStrategyTemplate_Interface(t *testing.T) {
|
||||
// 所有策略实现必须实现SelectProvider, Name, Type方法
|
||||
|
||||
// 创建策略实现示例
|
||||
costBased := &CostBasedTemplate{
|
||||
name: "CostBased",
|
||||
}
|
||||
|
||||
aware := &CostAwareTemplate{
|
||||
name: "CostAware",
|
||||
}
|
||||
|
||||
// 验证实现了StrategyTemplate接口
|
||||
var _ StrategyTemplate = costBased
|
||||
var _ StrategyTemplate = aware
|
||||
|
||||
// 验证方法
|
||||
assert.Equal(t, "CostBased", costBased.Name())
|
||||
assert.Equal(t, "cost_based", costBased.Type())
|
||||
|
||||
assert.Equal(t, "CostAware", aware.Name())
|
||||
assert.Equal(t, "cost_aware", aware.Type())
|
||||
}
|
||||
|
||||
// TestStrategyTemplate_SelectProvider_Signature 验证SelectProvider方法签名
|
||||
func TestStrategyTemplate_SelectProvider_Signature(t *testing.T) {
|
||||
req := &RoutingRequest{
|
||||
Model: "gpt-4",
|
||||
UserID: "user123",
|
||||
TenantID: "tenant1",
|
||||
MaxCost: 1.0,
|
||||
MaxLatency: 1000,
|
||||
}
|
||||
|
||||
// 验证返回值 - 创建一个有providers的模板
|
||||
template := &CostBasedTemplate{
|
||||
name: "test",
|
||||
maxCostPer1KTokens: 1.0,
|
||||
providers: make(map[string]adapter.ProviderAdapter),
|
||||
}
|
||||
template.providers["test"] = &MockProvider{
|
||||
name: "test",
|
||||
costPer1KTokens: 0.5,
|
||||
available: true,
|
||||
models: []string{"gpt-4"},
|
||||
}
|
||||
|
||||
decision, err := template.SelectProvider(context.Background(), req)
|
||||
|
||||
// 接口实现应该返回决策或错误
|
||||
assert.NotNil(t, decision)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
40
gateway/internal/router/strategy/types.go
Normal file
40
gateway/internal/router/strategy/types.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package strategy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// RoutingRequest 路由请求
|
||||
type RoutingRequest struct {
|
||||
Model string
|
||||
UserID string
|
||||
TenantID string
|
||||
Region string
|
||||
Messages []string
|
||||
MaxCost float64
|
||||
MaxLatency int64
|
||||
MinQuality float64
|
||||
}
|
||||
|
||||
// RoutingDecision 路由决策
|
||||
type RoutingDecision struct {
|
||||
Provider string
|
||||
Strategy string
|
||||
CostPer1KTokens float64
|
||||
EstimatedLatency int64
|
||||
QualityScore float64
|
||||
TakeoverMark bool // M-008: 是否标记为接管
|
||||
}
|
||||
|
||||
// StrategyTemplate 策略模板接口
|
||||
// 所有路由策略都必须实现此接口
|
||||
type StrategyTemplate interface {
|
||||
// SelectProvider 选择最佳Provider
|
||||
SelectProvider(ctx context.Context, req *RoutingRequest) (*RoutingDecision, error)
|
||||
|
||||
// Name 获取策略名称
|
||||
Name() string
|
||||
|
||||
// Type 获取策略类型
|
||||
Type() string
|
||||
}
|
||||
Reference in New Issue
Block a user