455 lines
17 KiB
Go
455 lines
17 KiB
Go
package store
|
|
|
|
import "testing"
|
|
|
|
func TestNormalizeModelMatchKeyRemovesWhitespace(t *testing.T) {
|
|
got := normalizeModelMatchKey(" doubao-5.0 图像\t编辑\n")
|
|
if got != "doubao-5.0图像编辑" {
|
|
t.Fatalf("expected whitespace-insensitive model key, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTaskBillingModelIdentityKeepsRequestedModelPrimary(t *testing.T) {
|
|
identity := taskBillingModelIdentity(GatewayTask{
|
|
Model: "doubao-5.0 图像编辑",
|
|
RequestedModel: "doubao-5.0 图像编辑",
|
|
ResolvedModel: "doubao-image-real",
|
|
Metrics: map[string]any{
|
|
"modelAlias": "doubao-5.0图像编辑",
|
|
"modelName": "doubao-image-real",
|
|
"providerModel": "doubao-provider-image",
|
|
},
|
|
})
|
|
|
|
if identity.Model != "doubao-5.0 图像编辑" || identity.RequestedModel != "doubao-5.0 图像编辑" {
|
|
t.Fatalf("expected persisted model to keep requested model, got %+v", identity)
|
|
}
|
|
if identity.ResolvedModel != "doubao-5.0图像编辑" {
|
|
t.Fatalf("expected resolved model to keep system alias, got %+v", identity)
|
|
}
|
|
if identity.ModelName != "doubao-image-real" || identity.ProviderModelName != "doubao-provider-image" {
|
|
t.Fatalf("expected model name/provider model to stay available, got %+v", identity)
|
|
}
|
|
}
|
|
|
|
func TestTaskBillingModelIdentityFallsBackToBillingLines(t *testing.T) {
|
|
identity := taskBillingModelIdentity(GatewayTask{
|
|
Model: "front end alias",
|
|
Billings: []any{
|
|
map[string]any{
|
|
"model": "system-model-name",
|
|
"modelAlias": "System Model Alias",
|
|
},
|
|
},
|
|
})
|
|
|
|
if identity.Model != "front end alias" || identity.ModelName != "system-model-name" || identity.ResolvedModel != "System Model Alias" {
|
|
t.Fatalf("expected billing lines to provide system model identity, got %+v", identity)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCandidateLoadUsesMaxLimitedMetric(t *testing.T) {
|
|
candidate := RuntimeModelCandidate{}
|
|
applyRuntimeCandidateLoad(&candidate, runtimeCandidateLoadInput{
|
|
Policy: map[string]any{"rules": []any{
|
|
map[string]any{"metric": "rpm", "limit": 100},
|
|
map[string]any{"metric": "tpm_total", "limit": 1000},
|
|
map[string]any{"metric": "concurrent", "limit": 10},
|
|
}},
|
|
RPMUsed: 40,
|
|
RPMReserved: 10,
|
|
TPMUsed: 900,
|
|
ConcurrentActive: 3,
|
|
QueuedWaiting: 2,
|
|
})
|
|
|
|
if !candidate.LoadLimited {
|
|
t.Fatal("expected load to be limited when rate limit rules exist")
|
|
}
|
|
if candidate.LoadMetrics.RPMRatio != 0.5 {
|
|
t.Fatalf("expected rpm ratio 0.5, got %v", candidate.LoadMetrics.RPMRatio)
|
|
}
|
|
if candidate.LoadMetrics.TPMRatio != 0.9 {
|
|
t.Fatalf("expected tpm ratio 0.9, got %v", candidate.LoadMetrics.TPMRatio)
|
|
}
|
|
if candidate.LoadMetrics.ConcurrentRatio != 0.5 {
|
|
t.Fatalf("expected concurrent ratio 0.5, got %v", candidate.LoadMetrics.ConcurrentRatio)
|
|
}
|
|
if candidate.LoadRatio != 0.9 {
|
|
t.Fatalf("expected max load ratio 0.9, got %v", candidate.LoadRatio)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCandidateSortingAvoidsFullCandidatesButKeepsFallback(t *testing.T) {
|
|
candidates := []RuntimeModelCandidate{
|
|
{
|
|
PlatformID: "high-priority-full",
|
|
PlatformPriority: 1,
|
|
LoadLimited: true,
|
|
LoadRatio: 1.2,
|
|
},
|
|
{
|
|
PlatformID: "lower-priority-available",
|
|
PlatformPriority: 50,
|
|
LoadLimited: true,
|
|
LoadRatio: 0.2,
|
|
},
|
|
}
|
|
|
|
sortRuntimeModelCandidates(candidates)
|
|
|
|
if candidates[0].PlatformID != "lower-priority-available" {
|
|
t.Fatalf("expected non-full candidate to be tried first, got %+v", candidates)
|
|
}
|
|
if candidates[1].PlatformID != "high-priority-full" || !candidates[1].LoadAvoided {
|
|
t.Fatalf("expected full high-priority candidate to remain as avoided fallback, got %+v", candidates)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCandidateSortingPrefersCacheAffinityWithinAvailableCandidates(t *testing.T) {
|
|
candidates := []RuntimeModelCandidate{
|
|
{
|
|
PlatformID: "base-priority",
|
|
PlatformPriority: 10,
|
|
},
|
|
{
|
|
PlatformID: "cache-affinity",
|
|
PlatformPriority: 20,
|
|
},
|
|
}
|
|
applyRuntimeCandidateCacheAffinity(&candidates[0], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "explicit:test",
|
|
CacheAffinityPolicy: map[string]any{"enabled": true, "minSamples": 3, "maxPriorityBoost": 20, "modelTypes": []any{"text_generate"}},
|
|
}, runtimeCandidateCacheAffinityInput{
|
|
RequestCount: 1,
|
|
EMAHitRatio: 1,
|
|
})
|
|
applyRuntimeCandidateCacheAffinity(&candidates[1], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "explicit:test",
|
|
CacheAffinityPolicy: map[string]any{"enabled": true, "minSamples": 3, "maxPriorityBoost": 20, "modelTypes": []any{"text_generate"}},
|
|
}, runtimeCandidateCacheAffinityInput{
|
|
RequestCount: 3,
|
|
EMAHitRatio: 0.9,
|
|
})
|
|
|
|
sortRuntimeModelCandidates(candidates)
|
|
|
|
if candidates[0].PlatformID != "cache-affinity" || !candidates[0].CacheAffinity.Applied {
|
|
t.Fatalf("expected cache affinity candidate first, got %+v", candidates)
|
|
}
|
|
if candidates[1].PlatformID != "base-priority" || candidates[1].CacheAffinity.Applied {
|
|
t.Fatalf("expected low sample candidate to keep base priority, got %+v", candidates)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCandidateSortingPromotesObservedCacheHitAbovePriority(t *testing.T) {
|
|
candidates := []RuntimeModelCandidate{
|
|
{
|
|
PlatformID: "volces-priority",
|
|
PlatformPriority: 1,
|
|
},
|
|
{
|
|
PlatformID: "deepseek-cache-hit",
|
|
PlatformPriority: 100,
|
|
},
|
|
}
|
|
policy := map[string]any{"enabled": true, "minSamples": 3, "maxPriorityBoost": 20, "modelTypes": []any{"text_generate"}}
|
|
applyRuntimeCandidateCacheAffinity(&candidates[0], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "conversation:test",
|
|
CacheAffinityPolicy: policy,
|
|
}, runtimeCandidateCacheAffinityInput{})
|
|
applyRuntimeCandidateCacheAffinity(&candidates[1], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "conversation:test",
|
|
CacheAffinityPolicy: policy,
|
|
}, runtimeCandidateCacheAffinityInput{
|
|
RequestCount: 1,
|
|
InputTokens: 7945,
|
|
CachedInputTokens: 7296,
|
|
EMAHitRatio: 0.918,
|
|
LastHitRatio: 0.918,
|
|
})
|
|
|
|
sortRuntimeModelCandidates(candidates)
|
|
|
|
if candidates[0].PlatformID != "deepseek-cache-hit" || !candidates[0].CacheAffinity.Applied {
|
|
t.Fatalf("expected observed cache hit to outrank platform priority, got %+v", candidates)
|
|
}
|
|
if candidates[0].CacheAffinity.Score <= 0 || candidates[0].CacheAffinity.Boost <= 0 {
|
|
t.Fatalf("expected observed cache hit to carry score and boost, got %+v", candidates[0].CacheAffinity)
|
|
}
|
|
if candidates[1].PlatformID != "volces-priority" || candidates[1].CacheAffinity.Applied {
|
|
t.Fatalf("expected priority-only candidate second, got %+v", candidates)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCandidateSortingOrdersMultipleCacheAffinityCandidatesByScore(t *testing.T) {
|
|
candidates := []RuntimeModelCandidate{
|
|
{
|
|
PlatformID: "lower-cache-score",
|
|
PlatformPriority: 1,
|
|
},
|
|
{
|
|
PlatformID: "higher-cache-score",
|
|
PlatformPriority: 50,
|
|
},
|
|
}
|
|
policy := map[string]any{"enabled": true, "minSamples": 1, "maxPriorityBoost": 20, "modelTypes": []any{"text_generate"}}
|
|
applyRuntimeCandidateCacheAffinity(&candidates[0], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "conversation:test",
|
|
CacheAffinityPolicy: policy,
|
|
}, runtimeCandidateCacheAffinityInput{
|
|
RequestCount: 2,
|
|
InputTokens: 8000,
|
|
CachedInputTokens: 1000,
|
|
EMAHitRatio: 0.5,
|
|
LastHitRatio: 0.5,
|
|
})
|
|
applyRuntimeCandidateCacheAffinity(&candidates[1], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "conversation:test",
|
|
CacheAffinityPolicy: policy,
|
|
}, runtimeCandidateCacheAffinityInput{
|
|
RequestCount: 1,
|
|
InputTokens: 8000,
|
|
CachedInputTokens: 7000,
|
|
EMAHitRatio: 0.75,
|
|
LastHitRatio: 0.75,
|
|
})
|
|
|
|
sortRuntimeModelCandidates(candidates)
|
|
|
|
if candidates[0].PlatformID != "higher-cache-score" {
|
|
t.Fatalf("expected higher cache score candidate first, got %+v", candidates)
|
|
}
|
|
if candidates[0].CacheAffinity.Score <= candidates[1].CacheAffinity.Score {
|
|
t.Fatalf("expected score to drive cache-affinity order, got %+v", candidates)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCandidateSortingKeepsFullCacheAffinityCandidateAvoided(t *testing.T) {
|
|
candidates := []RuntimeModelCandidate{
|
|
{
|
|
PlatformID: "cache-affinity-full",
|
|
PlatformPriority: 50,
|
|
LoadLimited: true,
|
|
LoadRatio: 1.2,
|
|
},
|
|
{
|
|
PlatformID: "available",
|
|
PlatformPriority: 10,
|
|
},
|
|
}
|
|
applyRuntimeCandidateCacheAffinity(&candidates[0], ListModelCandidatesOptions{
|
|
CacheAffinityKey: "explicit:test",
|
|
CacheAffinityPolicy: map[string]any{"enabled": true, "minSamples": 1, "maxPriorityBoost": 100, "modelTypes": []any{"text_generate"}},
|
|
}, runtimeCandidateCacheAffinityInput{
|
|
RequestCount: 10,
|
|
EMAHitRatio: 1,
|
|
})
|
|
|
|
sortRuntimeModelCandidates(candidates)
|
|
|
|
if candidates[0].PlatformID != "available" {
|
|
t.Fatalf("expected available candidate before full cache-affinity candidate, got %+v", candidates)
|
|
}
|
|
if candidates[1].PlatformID != "cache-affinity-full" || !candidates[1].LoadAvoided {
|
|
t.Fatalf("expected full cache-affinity candidate to remain avoided fallback, got %+v", candidates)
|
|
}
|
|
}
|
|
|
|
func TestDefaultRunnerPriorityDemotePolicyUsesAutoMode(t *testing.T) {
|
|
policy := defaultRunnerPriorityDemotePolicy()
|
|
|
|
if _, ok := policy["demoteStep"]; ok {
|
|
t.Fatal("priority demotion should be automatic and must not expose a demoteStep policy")
|
|
}
|
|
if policy["enabled"] != true {
|
|
t.Fatalf("expected default priority demotion to stay enabled, got %+v", policy["enabled"])
|
|
}
|
|
if policy["deprecated"] != true || policy["replacedBy"] != "failoverPolicy.actionRules" {
|
|
t.Fatalf("expected legacy priority demotion policy to be marked deprecated, got %+v", policy)
|
|
}
|
|
}
|
|
|
|
func TestDefaultRunnerFailoverPolicyCarriesComprehensiveDefaultsInActionRules(t *testing.T) {
|
|
policy := defaultRunnerFailoverPolicy()
|
|
if policy["legacyFieldsDeprecated"] != true || policy["replacedBy"] != "actionRules" {
|
|
t.Fatalf("expected legacy failover summary fields to be marked deprecated, got %+v", policy)
|
|
}
|
|
rules, ok := policy["actionRules"].([]any)
|
|
if !ok || len(rules) < 5 {
|
|
t.Fatalf("expected default action rules to be present, got %+v", policy["actionRules"])
|
|
}
|
|
|
|
cooldownRule := defaultActionRuleByAction(t, rules, "cooldown_and_next")
|
|
for _, code := range []string{"rate_limit", "too_many_requests", "resource_exhausted", "throttled", "quota_exceeded", "rpm_limit", "tpm_limit"} {
|
|
if !actionRuleHasString(cooldownRule, "codes", code) {
|
|
t.Fatalf("expected cooldown rule to include rate-limit code %q, got %+v", code, cooldownRule["codes"])
|
|
}
|
|
}
|
|
for _, keyword := range []string{"rate limit", "too many requests", "resource exhausted", "request limit", "requests per minute", "tokens per minute"} {
|
|
if !actionRuleHasString(cooldownRule, "keywords", keyword) {
|
|
t.Fatalf("expected cooldown rule to include rate-limit keyword %q, got %+v", keyword, cooldownRule["keywords"])
|
|
}
|
|
}
|
|
if !actionRuleHasInt(cooldownRule, "statusCodes", 429) {
|
|
t.Fatalf("expected cooldown rule to include 429, got %+v", cooldownRule["statusCodes"])
|
|
}
|
|
|
|
disableRule := defaultActionRuleByAction(t, rules, "disable_and_next")
|
|
for _, code := range []string{"auth_failed", "invalid_api_key", "missing_credentials", "account_disabled", "billing_not_active", "permission_denied"} {
|
|
if !actionRuleHasString(disableRule, "codes", code) {
|
|
t.Fatalf("expected disable rule to include auth/platform code %q, got %+v", code, disableRule["codes"])
|
|
}
|
|
}
|
|
for _, keyword := range []string{"invalid api key", "unauthorized", "credential", "permission denied", "account disabled", "billing_not_active", "余额不足"} {
|
|
if !actionRuleHasString(disableRule, "keywords", keyword) {
|
|
t.Fatalf("expected disable rule to include auth/platform keyword %q, got %+v", keyword, disableRule["keywords"])
|
|
}
|
|
}
|
|
for _, status := range []int{401, 403} {
|
|
if !actionRuleHasInt(disableRule, "statusCodes", status) {
|
|
t.Fatalf("expected disable rule to include status %d, got %+v", status, disableRule["statusCodes"])
|
|
}
|
|
}
|
|
|
|
demoteRule := defaultActionRuleByAction(t, rules, "demote_and_next")
|
|
for _, category := range []string{"timeout", "provider_5xx", "provider_overloaded"} {
|
|
if !actionRuleHasString(demoteRule, "categories", category) {
|
|
t.Fatalf("expected demote rule to include transient category %q, got %+v", category, demoteRule["categories"])
|
|
}
|
|
}
|
|
for _, code := range []string{"timeout", "server_error", "overloaded", "provider_failed", "invalid_response", "script_timeout", "upload_failed"} {
|
|
if !actionRuleHasString(demoteRule, "codes", code) {
|
|
t.Fatalf("expected demote rule to include transient code %q, got %+v", code, demoteRule["codes"])
|
|
}
|
|
}
|
|
for _, status := range []int{408, 500, 502, 503, 504, 520, 522, 524, 529, 530} {
|
|
if !actionRuleHasInt(demoteRule, "statusCodes", status) {
|
|
t.Fatalf("expected demote rule to include transient status %d, got %+v", status, demoteRule["statusCodes"])
|
|
}
|
|
}
|
|
for _, keyword := range []string{"deadline exceeded", "temporarily unavailable", "service unavailable", "bad gateway", "upstream timeout", "try again later"} {
|
|
if !actionRuleHasString(demoteRule, "keywords", keyword) {
|
|
t.Fatalf("expected demote rule to include transient keyword %q, got %+v", keyword, demoteRule["keywords"])
|
|
}
|
|
}
|
|
|
|
nextRule := defaultActionRuleByAction(t, rules, "next")
|
|
for _, category := range []string{"network", "stream_error"} {
|
|
if !actionRuleHasString(nextRule, "categories", category) {
|
|
t.Fatalf("expected next rule to include network category %q, got %+v", category, nextRule["categories"])
|
|
}
|
|
}
|
|
for _, code := range []string{"network", "stream_read_error", "upload_network", "upload_config_failed", "upload_source_fetch_failed"} {
|
|
if !actionRuleHasString(nextRule, "codes", code) {
|
|
t.Fatalf("expected next rule to include network code %q, got %+v", code, nextRule["codes"])
|
|
}
|
|
}
|
|
for _, keyword := range []string{"socket hang up", "connection reset", "econnreset", "enotfound", "unexpected eof", "stream error", "fetch failed"} {
|
|
if !actionRuleHasString(nextRule, "keywords", keyword) {
|
|
t.Fatalf("expected next rule to include network keyword %q, got %+v", keyword, nextRule["keywords"])
|
|
}
|
|
}
|
|
|
|
stopRule := defaultActionRuleByAction(t, rules, "stop")
|
|
for _, code := range []string{"bad_request", "invalid_request", "invalid_parameter", "missing_required", "unsupported_kind", "unsupported_model", "request_asset_input_format_unsupported", "upload_source_too_large", "invalid_multipart_image", "script_error", "insufficient_balance", "permission_denied"} {
|
|
if !actionRuleHasString(stopRule, "codes", code) {
|
|
t.Fatalf("expected stop rule to include legacy hard-stop code %q, got %+v", code, stopRule["codes"])
|
|
}
|
|
}
|
|
for _, status := range []int{400, 404, 405, 409, 413, 415, 422} {
|
|
if !actionRuleHasInt(stopRule, "statusCodes", status) {
|
|
t.Fatalf("expected stop rule to include request-error status %d, got %+v", status, stopRule["statusCodes"])
|
|
}
|
|
}
|
|
for _, keyword := range []string{"invalid parameter", "missing required", "not supported", "image is required", "too large", "not found"} {
|
|
if !actionRuleHasString(stopRule, "keywords", keyword) {
|
|
t.Fatalf("expected stop rule to include hard-stop keyword %q, got %+v", keyword, stopRule["keywords"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLegacyDefaultRunnerActionRulesAreDetectedForUpgrade(t *testing.T) {
|
|
legacy := []any{
|
|
map[string]any{
|
|
"enabled": true,
|
|
"categories": []any{"network", "timeout", "stream_error", "provider_5xx", "provider_overloaded"},
|
|
"action": "next",
|
|
},
|
|
map[string]any{
|
|
"enabled": true,
|
|
"categories": []any{"rate_limit"},
|
|
"codes": []any{"rate_limit"},
|
|
"statusCodes": []any{429},
|
|
"keywords": []any{"rate_limit", "429", "too many requests"},
|
|
"action": "cooldown_and_next",
|
|
"target": "model",
|
|
"cooldownSeconds": 300,
|
|
},
|
|
map[string]any{
|
|
"enabled": true,
|
|
"keywords": []any{"rate limit", "too many requests", "temporarily_unavailable"},
|
|
"action": "demote_and_next",
|
|
"target": "platform",
|
|
"demoteSteps": 1,
|
|
},
|
|
map[string]any{
|
|
"enabled": true,
|
|
"categories": []any{"auth_error"},
|
|
"codes": []any{"auth_failed", "invalid_api_key", "missing_credentials"},
|
|
"statusCodes": []any{401, 403},
|
|
"keywords": []any{"invalid api key", "api key is invalid", "unauthorized", "forbidden", "permission denied"},
|
|
"action": "disable_and_next",
|
|
"target": "platform",
|
|
},
|
|
map[string]any{
|
|
"enabled": true,
|
|
"categories": []any{"request_error", "unsupported_model", "user_permission", "insufficient_balance"},
|
|
"codes": []any{"bad_request", "invalid_request", "invalid_parameter", "missing_required", "unsupported_kind", "unsupported_model", "insufficient_balance", "permission_denied"},
|
|
"keywords": []any{"invalid_parameter", "missing required", "bad request", "insufficient balance"},
|
|
"action": "stop",
|
|
},
|
|
}
|
|
if !runnerActionRulesLookLikeLegacyDefaults(legacy) {
|
|
t.Fatal("expected old compact default action rules to be detected")
|
|
}
|
|
if runnerActionRulesLookLikeLegacyDefaults(defaultRunnerFailoverPolicy()["actionRules"]) {
|
|
t.Fatal("enhanced defaults should not be treated as legacy")
|
|
}
|
|
}
|
|
|
|
func defaultActionRuleByAction(t *testing.T, rules []any, action string) map[string]any {
|
|
t.Helper()
|
|
for _, raw := range rules {
|
|
rule, ok := raw.(map[string]any)
|
|
if ok && rule["action"] == action {
|
|
return rule
|
|
}
|
|
}
|
|
t.Fatalf("expected action rule %q in %+v", action, rules)
|
|
return nil
|
|
}
|
|
|
|
func actionRuleHasString(rule map[string]any, key string, want string) bool {
|
|
for _, raw := range ruleSlice(rule, key) {
|
|
if raw == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func actionRuleHasInt(rule map[string]any, key string, want int) bool {
|
|
for _, raw := range ruleSlice(rule, key) {
|
|
if raw == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ruleSlice(rule map[string]any, key string) []any {
|
|
values, _ := rule[key].([]any)
|
|
return values
|
|
}
|