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 }