feat: add runner failover rules and cache affinity
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildCacheAffinityKey(kind string, modelType string, body map[string]any) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"cacheAffinityKey", "cache_affinity_key"} {
|
||||
if value := strings.TrimSpace(stringFromAny(body[key])); value != "" {
|
||||
return "explicit:" + sha256Text(value)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sessionId", "session_id", "conversationId", "conversation_id"} {
|
||||
if value := strings.TrimSpace(stringFromAny(body[key])); value != "" {
|
||||
return key + ":" + sha256Text(value)
|
||||
}
|
||||
}
|
||||
if !cacheAffinityPromptHashSupported(kind, modelType) {
|
||||
return ""
|
||||
}
|
||||
payload := map[string]any{
|
||||
"kind": kind,
|
||||
"modelType": modelType,
|
||||
}
|
||||
if messages, ok := body["messages"]; ok {
|
||||
payload["messages"] = messages
|
||||
}
|
||||
if tools, ok := body["tools"]; ok {
|
||||
payload["tools"] = tools
|
||||
}
|
||||
if instructions, ok := body["instructions"]; ok {
|
||||
payload["instructions"] = instructions
|
||||
}
|
||||
if input, ok := body["input"]; ok {
|
||||
payload["input"] = input
|
||||
}
|
||||
if len(payload) <= 2 {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "prompt:" + sha256Text(string(raw))
|
||||
}
|
||||
|
||||
func cacheAffinityPromptHashSupported(kind string, modelType string) bool {
|
||||
switch strings.TrimSpace(kind) {
|
||||
case "chat.completions", "responses":
|
||||
return true
|
||||
}
|
||||
switch strings.TrimSpace(modelType) {
|
||||
case "chat", "text_generate", "responses":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Text(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package runner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildCacheAffinityKeyUsesExplicitKeyFirst(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"cacheAffinityKey": "session-a",
|
||||
"sessionId": "session-b",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
}
|
||||
|
||||
got := buildCacheAffinityKey("chat.completions", "text_generate", body)
|
||||
if got == "" || got[:9] != "explicit:" {
|
||||
t.Fatalf("expected explicit cache affinity key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheAffinityKeyUsesSessionContinuity(t *testing.T) {
|
||||
got := buildCacheAffinityKey("chat.completions", "text_generate", map[string]any{
|
||||
"session_id": "conversation-1",
|
||||
})
|
||||
|
||||
if got == "" || got[:11] != "session_id:" {
|
||||
t.Fatalf("expected session cache affinity key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheAffinityKeyFallsBackToPromptHash(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": "stable"},
|
||||
map[string]any{"role": "user", "content": "hello"},
|
||||
},
|
||||
"tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "lookup"}}},
|
||||
}
|
||||
|
||||
first := buildCacheAffinityKey("chat.completions", "text_generate", body)
|
||||
second := buildCacheAffinityKey("chat.completions", "text_generate", body)
|
||||
if first == "" || first != second || first[:7] != "prompt:" {
|
||||
t.Fatalf("expected stable prompt hash cache affinity key, first=%q second=%q", first, second)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,35 @@ func TestBillingsSplitsCachedInputTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingsDefaultsCachedInputPriceToOneTenthOfInputPrice(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelName: "test-model",
|
||||
BillingConfig: map[string]any{
|
||||
"textInputPer1k": 1.0,
|
||||
"textOutputPer1k": 2.0,
|
||||
},
|
||||
}
|
||||
response := clients.Response{
|
||||
Usage: clients.Usage{
|
||||
InputTokens: 1000,
|
||||
CachedInputTokens: 400,
|
||||
OutputTokens: 500,
|
||||
TotalTokens: 1500,
|
||||
},
|
||||
}
|
||||
|
||||
lines := service.billings(context.Background(), nil, "chat.completions", nil, candidate, response, false)
|
||||
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected uncached input, cached input, and output lines, got %+v", lines)
|
||||
}
|
||||
cached, _ := lines[1].(map[string]any)
|
||||
if cached["resourceType"] != "text_cached_input" || cached["quantity"] != 400 || cached["amount"] != 0.04 {
|
||||
t.Fatalf("unexpected cached input fallback billing line: %+v", cached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRateLimitPolicyTreatsEmptyRuntimePolicyAsUnlimited(t *testing.T) {
|
||||
policy := effectiveRateLimitPolicy(store.RuntimeModelCandidate{
|
||||
PlatformRateLimitPolicy: map[string]any{"rules": []any{
|
||||
|
||||
@@ -82,7 +82,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
inputPrice := resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice")
|
||||
cachedInputPrice := resourcePrice(config, "text", "textCachedInputPer1k", "cachedInputTokenPrice", "cacheInputTokenPrice", "textInputCacheHitPer1k", "inputCacheHitTokenPrice")
|
||||
if cachedInputPrice <= 0 {
|
||||
cachedInputPrice = inputPrice
|
||||
cachedInputPrice = inputPrice / 10
|
||||
}
|
||||
inputAmount := roundPrice(float64(uncachedInputTokens) / 1000 * inputPrice * discount)
|
||||
lines := []any{}
|
||||
|
||||
@@ -139,6 +139,19 @@ func attemptMetrics(candidate store.RuntimeModelCandidate, attemptNo int, simula
|
||||
"queued": candidate.LoadMetrics.QueuedCount,
|
||||
}
|
||||
}
|
||||
if candidate.CacheAffinity.Key != "" {
|
||||
metrics["cacheAffinityKey"] = candidate.CacheAffinity.Key
|
||||
metrics["cacheAdjustedPriority"] = candidate.CacheAffinity.AdjustedPriority
|
||||
metrics["cacheAffinitySamples"] = candidate.CacheAffinity.RequestCount
|
||||
metrics["cacheAffinityScore"] = candidate.CacheAffinity.Score
|
||||
metrics["cacheAffinityConfidence"] = candidate.CacheAffinity.Confidence
|
||||
metrics["cacheAffinityHitRatio"] = candidate.CacheAffinity.EMAHitRatio
|
||||
metrics["cacheAffinityEMAHitRatio"] = candidate.CacheAffinity.EMAHitRatio
|
||||
metrics["cacheAffinityLastHitRatio"] = candidate.CacheAffinity.LastHitRatio
|
||||
metrics["cacheAffinityCachedInputTokens"] = candidate.CacheAffinity.CachedInputTokens
|
||||
metrics["cacheAffinityBoost"] = candidate.CacheAffinity.Boost
|
||||
metrics["cacheAffinityApplied"] = candidate.CacheAffinity.Applied
|
||||
}
|
||||
if attemptNo > 0 {
|
||||
metrics["attempt"] = attemptNo
|
||||
}
|
||||
@@ -151,9 +164,10 @@ func usageToMap(usage clients.Usage) map[string]any {
|
||||
out["inputTokens"] = usage.InputTokens
|
||||
out["promptTokens"] = usage.InputTokens
|
||||
}
|
||||
if usage.CachedInputTokens > 0 {
|
||||
if usage.CachedInputTokensKnown || usage.CachedInputTokens > 0 {
|
||||
out["cachedInputTokens"] = usage.CachedInputTokens
|
||||
out["cachedPromptTokens"] = usage.CachedInputTokens
|
||||
out["cachedInputTokensKnown"] = usage.CachedInputTokensKnown
|
||||
}
|
||||
if usage.OutputTokens > 0 {
|
||||
out["outputTokens"] = usage.OutputTokens
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestAttemptMetricsIncludesCacheAffinityCoefficient(t *testing.T) {
|
||||
metrics := attemptMetrics(store.RuntimeModelCandidate{
|
||||
ModelName: "deepseek-chat",
|
||||
ModelType: "text_generate",
|
||||
PlatformID: "deepseek-platform",
|
||||
PlatformPriority: 100,
|
||||
CacheAffinity: store.RuntimeCandidateCacheAffinity{
|
||||
Key: "conversation:test",
|
||||
RequestCount: 2,
|
||||
CachedInputTokens: 7296,
|
||||
EMAHitRatio: 0.91,
|
||||
LastHitRatio: 0.92,
|
||||
Confidence: 1,
|
||||
Score: 1.74,
|
||||
Boost: 20,
|
||||
AdjustedPriority: 80,
|
||||
Applied: true,
|
||||
},
|
||||
}, 1, false)
|
||||
|
||||
if metrics["cacheAffinityScore"] != 1.74 {
|
||||
t.Fatalf("expected cache affinity score in attempt metrics, got %+v", metrics)
|
||||
}
|
||||
if metrics["cacheAffinityCachedInputTokens"] != 7296 {
|
||||
t.Fatalf("expected cached input tokens in attempt metrics, got %+v", metrics)
|
||||
}
|
||||
if metrics["cacheAffinityHitRatio"] != 0.91 || metrics["cacheAffinityLastHitRatio"] != 0.92 {
|
||||
t.Fatalf("expected hit ratios in attempt metrics, got %+v", metrics)
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,20 @@ type retryDecision struct {
|
||||
type failoverDecision struct {
|
||||
Retry bool
|
||||
Action string
|
||||
Target string
|
||||
Reason string
|
||||
CooldownSeconds int
|
||||
DemoteSteps int
|
||||
Match policyRuleMatch
|
||||
Info failureInfo
|
||||
}
|
||||
|
||||
type priorityDemoteDecision struct {
|
||||
Demote bool
|
||||
Reason string
|
||||
Match policyRuleMatch
|
||||
Info failureInfo
|
||||
Demote bool
|
||||
Reason string
|
||||
DemoteSteps int
|
||||
Match policyRuleMatch
|
||||
Info failureInfo
|
||||
}
|
||||
|
||||
func shouldRetrySameClient(candidate store.RuntimeModelCandidate, err error) bool {
|
||||
@@ -77,10 +80,13 @@ func failoverDecisionForCandidate(runnerPolicy store.RunnerPolicy, candidate sto
|
||||
if strings.TrimSpace(runnerPolicy.Status) != "" && runnerPolicy.Status != "active" {
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "runner_policy_disabled", Match: policyRuleMatch{Source: "gateway_runner_policies", Policy: "runnerPolicy", Rule: "status", Value: runnerPolicy.Status}, Info: info}
|
||||
}
|
||||
if match, ok := hardStopPolicyMatch(runnerPolicy.HardStopPolicy, info); ok {
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "hard_stop_policy", Match: match, Info: info}
|
||||
}
|
||||
overridePolicy := failoverOverridePolicy(candidate.RuntimePolicyOverride)
|
||||
hasActionRules := len(actionRulesFromPolicy(runnerPolicy.FailoverPolicy)) > 0 || len(actionRulesFromPolicy(overridePolicy)) > 0
|
||||
if !hasActionRules {
|
||||
if match, ok := hardStopPolicyMatch(runnerPolicy.HardStopPolicy, info); ok {
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "hard_stop_policy", Match: match, Info: info}
|
||||
}
|
||||
}
|
||||
policy := effectiveFailoverPolicy(runnerPolicy.FailoverPolicy, candidate.RuntimePolicyOverride)
|
||||
if !boolFromPolicy(policy, "enabled", true) {
|
||||
source := "gateway_runner_policies.failover_policy"
|
||||
@@ -89,22 +95,26 @@ func failoverDecisionForCandidate(runnerPolicy store.RunnerPolicy, candidate sto
|
||||
}
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "failover_disabled", Match: policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "enabled", Value: "false"}, Info: info}
|
||||
}
|
||||
if errors.Is(err, store.ErrRateLimited) {
|
||||
return failoverDecision{Retry: false, Action: "queue", Reason: "local_rate_limit_wait_queue", Match: policyRuleMatch{Source: "gateway_rate_limits", Policy: "rateLimitPolicy", Rule: "localCapacity", Value: "exceeded"}, Info: info}
|
||||
}
|
||||
if ruleDecision, ok := failoverActionRuleDecisionWithSources(runnerPolicy.FailoverPolicy, overridePolicy, info); ok {
|
||||
return ruleDecision
|
||||
}
|
||||
if match, ok := failoverDenyMatchWithSources(runnerPolicy.FailoverPolicy, overridePolicy, info); ok {
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "failover_deny_policy", Match: match, Info: info}
|
||||
}
|
||||
action := failoverAction(policy, info)
|
||||
target := defaultFailoverActionTarget(action)
|
||||
cooldownSeconds := intFromPolicy(policy, "cooldownSeconds")
|
||||
if cooldownSeconds <= 0 {
|
||||
cooldownSeconds = 300
|
||||
}
|
||||
if errors.Is(err, store.ErrRateLimited) {
|
||||
return failoverDecision{Retry: false, Action: "queue", Reason: "local_rate_limit_wait_queue", Match: policyRuleMatch{Source: "gateway_rate_limits", Policy: "rateLimitPolicy", Rule: "localCapacity", Value: "exceeded"}, Info: info}
|
||||
}
|
||||
if match, ok := failoverAllowMatchWithSources(runnerPolicy.FailoverPolicy, overridePolicy, info); ok {
|
||||
return failoverDecision{Retry: true, Action: action, Reason: "failover_allow_policy", CooldownSeconds: cooldownSeconds, Match: match, Info: info}
|
||||
return failoverDecision{Retry: true, Action: action, Target: target, Reason: "failover_allow_policy", CooldownSeconds: cooldownSeconds, DemoteSteps: 1, Match: match, Info: info}
|
||||
}
|
||||
if clients.IsRetryable(err) {
|
||||
return failoverDecision{Retry: true, Action: action, Reason: "client_retryable", CooldownSeconds: cooldownSeconds, Match: policyRuleMatch{Source: "provider_client", Policy: "ClientError", Rule: "Retryable", Value: "true"}, Info: info}
|
||||
return failoverDecision{Retry: true, Action: action, Target: target, Reason: "client_retryable", CooldownSeconds: cooldownSeconds, DemoteSteps: 1, Match: policyRuleMatch{Source: "provider_client", Policy: "ClientError", Rule: "Retryable", Value: "true"}, Info: info}
|
||||
}
|
||||
return failoverDecision{Retry: false, Action: "stop", Reason: "client_non_retryable", Match: policyRuleMatch{Source: "provider_client", Policy: "ClientError", Rule: "Retryable", Value: "false"}, Info: info}
|
||||
}
|
||||
@@ -122,12 +132,15 @@ func priorityDemoteDecisionForCandidate(runnerPolicy store.RunnerPolicy, err err
|
||||
if hardStopPolicyMatches(runnerPolicy.HardStopPolicy, info) {
|
||||
return priorityDemoteDecision{Demote: false, Reason: "hard_stop_policy", Info: info}
|
||||
}
|
||||
if len(actionRulesFromPolicy(runnerPolicy.FailoverPolicy)) > 0 {
|
||||
return priorityDemoteDecision{Demote: false, Reason: "failover_action_rules_enabled", Info: info}
|
||||
}
|
||||
policy := runnerPolicy.PriorityDemotePolicy
|
||||
if !boolFromPolicy(policy, "enabled", false) {
|
||||
return priorityDemoteDecision{Demote: false, Reason: "priority_demote_disabled", Info: info}
|
||||
}
|
||||
if match, ok := priorityDemotePolicyMatch(policy, info); ok {
|
||||
return priorityDemoteDecision{Demote: true, Reason: "priority_demote_policy", Match: match, Info: info}
|
||||
return priorityDemoteDecision{Demote: true, Reason: "priority_demote_policy", DemoteSteps: 99, Match: match, Info: info}
|
||||
}
|
||||
return priorityDemoteDecision{Demote: false, Reason: "priority_demote_no_match", Info: info}
|
||||
}
|
||||
@@ -299,11 +312,118 @@ func priorityDemotePolicyMatch(policy map[string]any, info failureInfo) (policyR
|
||||
func failoverAction(policy map[string]any, info failureInfo) string {
|
||||
actions, _ := policy["actions"].(map[string]any)
|
||||
if action := stringFromAny(actions[info.Category]); action != "" {
|
||||
return action
|
||||
return normalizeFailoverAction(action)
|
||||
}
|
||||
return "next"
|
||||
}
|
||||
|
||||
func failoverActionRuleDecisionWithSources(base map[string]any, override map[string]any, info failureInfo) (failoverDecision, bool) {
|
||||
if rules := actionRulesFromPolicy(override); len(rules) > 0 {
|
||||
return failoverActionRuleDecision(rules, "runtime_policy_override.failoverPolicy", info)
|
||||
}
|
||||
return failoverActionRuleDecision(actionRulesFromPolicy(base), "gateway_runner_policies.failover_policy", info)
|
||||
}
|
||||
|
||||
func failoverActionRuleDecision(rules []map[string]any, source string, info failureInfo) (failoverDecision, bool) {
|
||||
for index, rule := range rules {
|
||||
if !boolFromPolicy(rule, "enabled", true) {
|
||||
continue
|
||||
}
|
||||
match, ok := failoverActionRuleMatch(rule, info, source, index)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
action := normalizeFailoverAction(stringFromAny(rule["action"]))
|
||||
target := normalizeFailoverTarget(stringFromAny(rule["target"]), action)
|
||||
cooldownSeconds := intFromPolicy(rule, "cooldownSeconds")
|
||||
if cooldownSeconds <= 0 {
|
||||
cooldownSeconds = 300
|
||||
}
|
||||
demoteSteps := intFromPolicy(rule, "demoteSteps")
|
||||
if demoteSteps <= 0 {
|
||||
demoteSteps = 1
|
||||
}
|
||||
if action == "stop" {
|
||||
return failoverDecision{Retry: false, Action: "stop", Target: target, Reason: "failover_action_rule", CooldownSeconds: cooldownSeconds, DemoteSteps: demoteSteps, Match: match, Info: info}, true
|
||||
}
|
||||
return failoverDecision{Retry: true, Action: action, Target: target, Reason: "failover_action_rule", CooldownSeconds: cooldownSeconds, DemoteSteps: demoteSteps, Match: match, Info: info}, true
|
||||
}
|
||||
return failoverDecision{}, false
|
||||
}
|
||||
|
||||
func actionRulesFromPolicy(policy map[string]any) []map[string]any {
|
||||
raw, ok := policy["actionRules"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rules := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
rule, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func failoverActionRuleMatch(rule map[string]any, info failureInfo, source string, index int) (policyRuleMatch, bool) {
|
||||
source = fmt.Sprintf("%s.actionRules[%d]", source, index)
|
||||
if value, ok := matchingStringListValue(rule, "categories", info.Category); ok {
|
||||
return policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "categories", Value: value}, true
|
||||
}
|
||||
if value, ok := matchingStringListValue(rule, "codes", info.Code); ok {
|
||||
return policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "codes", Value: value}, true
|
||||
}
|
||||
if value, ok := matchingStringListValue(rule, "errorCodes", info.Code); ok {
|
||||
return policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "errorCodes", Value: value}, true
|
||||
}
|
||||
if value, ok := matchingStringListValue(rule, "errorCodes", info.Category); ok {
|
||||
return policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "errorCodes", Value: value}, true
|
||||
}
|
||||
if value, ok := matchingIntListValue(rule, "statusCodes", info.Status); ok {
|
||||
return policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "statusCodes", Value: fmt.Sprintf("%d", value)}, true
|
||||
}
|
||||
if value, ok := matchingKeywordValue(rule, "keywords", info.Target); ok {
|
||||
return policyRuleMatch{Source: source, Policy: "failoverPolicy", Rule: "keywords", Value: value}, true
|
||||
}
|
||||
return policyRuleMatch{}, false
|
||||
}
|
||||
|
||||
func normalizeFailoverAction(action string) string {
|
||||
switch strings.TrimSpace(action) {
|
||||
case "rotate":
|
||||
return "next"
|
||||
case "cooldown_and_rotate":
|
||||
return "cooldown_and_next"
|
||||
case "demote_and_rotate":
|
||||
return "demote_and_next"
|
||||
case "disable_and_rotate":
|
||||
return "disable_and_next"
|
||||
case "cooldown_and_next", "demote_and_next", "disable_and_next", "stop", "next":
|
||||
return strings.TrimSpace(action)
|
||||
default:
|
||||
return "next"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFailoverTarget(target string, action string) string {
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
if target == "platform" || target == "model" {
|
||||
return target
|
||||
}
|
||||
return defaultFailoverActionTarget(action)
|
||||
}
|
||||
|
||||
func defaultFailoverActionTarget(action string) string {
|
||||
switch action {
|
||||
case "cooldown_and_next":
|
||||
return "model"
|
||||
default:
|
||||
return "platform"
|
||||
}
|
||||
}
|
||||
|
||||
func boolFromPolicy(policy map[string]any, key string, fallback bool) bool {
|
||||
value, ok := policy[key].(bool)
|
||||
if !ok {
|
||||
@@ -442,6 +562,13 @@ func intListFromPolicy(policy map[string]any, key string) []int {
|
||||
out = append(out, typed)
|
||||
case float64:
|
||||
out = append(out, int(typed))
|
||||
case string:
|
||||
if parsed := strings.TrimSpace(typed); parsed != "" {
|
||||
var value int
|
||||
if _, err := fmt.Sscanf(parsed, "%d", &value); err == nil && value > 0 {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -197,6 +197,97 @@ func TestProviderAuthErrorsFailOverInsteadOfHardStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailoverActionRulesControlPerRuleCooldownAndDemotion(t *testing.T) {
|
||||
runnerPolicy := store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"categories": []any{"rate_limit"},
|
||||
"action": "cooldown_and_next",
|
||||
"target": "platform",
|
||||
"cooldownSeconds": 45,
|
||||
},
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"keywords": []any{"temporary overload"},
|
||||
"action": "demote_and_next",
|
||||
"demoteSteps": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
decision := failoverDecisionForCandidate(runnerPolicy, store.RuntimeModelCandidate{}, &clients.ClientError{Code: "rate_limit", StatusCode: 429, Retryable: false})
|
||||
if !decision.Retry || decision.Action != "cooldown_and_next" || decision.Target != "platform" || decision.CooldownSeconds != 45 {
|
||||
t.Fatalf("rate limit should use per-rule cooldown action, got %+v", decision)
|
||||
}
|
||||
|
||||
decision = failoverDecisionForCandidate(runnerPolicy, store.RuntimeModelCandidate{}, &clients.ClientError{Code: "server_error", Message: "temporary overload from upstream", StatusCode: 503, Retryable: false})
|
||||
if !decision.Retry || decision.Action != "demote_and_next" || decision.DemoteSteps != 3 {
|
||||
t.Fatalf("keyword match should use per-rule demote action, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailoverActionRulesCanStopAndMatchStringStatusCodes(t *testing.T) {
|
||||
runnerPolicy := store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"statusCodes": []any{"400"},
|
||||
"keywords": []any{"bad request"},
|
||||
"action": "stop",
|
||||
},
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"categories": []any{"request_error"},
|
||||
"action": "next",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
decision := failoverDecisionForCandidate(runnerPolicy, store.RuntimeModelCandidate{}, &clients.ClientError{Code: "bad_request", Message: "bad request", StatusCode: 400, Retryable: true})
|
||||
if decision.Retry || decision.Action != "stop" || decision.Reason != "failover_action_rule" || decision.Match.Rule != "statusCodes" {
|
||||
t.Fatalf("first stop rule should win before later retry rules, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailoverActionRulesKeepOrderBeforeDerivedHardStopPolicy(t *testing.T) {
|
||||
runnerPolicy := store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"keywords": []any{"bad request retryable"},
|
||||
"action": "next",
|
||||
},
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"statusCodes": []any{400},
|
||||
"action": "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
HardStopPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"statusCodes": []any{400},
|
||||
},
|
||||
}
|
||||
|
||||
decision := failoverDecisionForCandidate(runnerPolicy, store.RuntimeModelCandidate{}, &clients.ClientError{Code: "bad_request", Message: "bad request retryable", StatusCode: 400, Retryable: true})
|
||||
if !decision.Retry || decision.Action != "next" || decision.Match.Rule != "keywords" {
|
||||
t.Fatalf("actionRules should keep list order before derived hard stop compatibility fields, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityDemotePolicyIsKeywordGatedAndHardStopSafe(t *testing.T) {
|
||||
runnerPolicy := store.RunnerPolicy{
|
||||
Status: "active",
|
||||
@@ -218,3 +309,28 @@ func TestPriorityDemotePolicyIsKeywordGatedAndHardStopSafe(t *testing.T) {
|
||||
t.Fatal("priority demotion should not run for hard-stop request errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityDemotePolicyIsSkippedWhenFailoverActionRulesExist(t *testing.T) {
|
||||
runnerPolicy := store.RunnerPolicy{
|
||||
Status: "active",
|
||||
FailoverPolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"actionRules": []any{
|
||||
map[string]any{
|
||||
"enabled": true,
|
||||
"categories": []any{"rate_limit"},
|
||||
"action": "demote_and_next",
|
||||
},
|
||||
},
|
||||
},
|
||||
PriorityDemotePolicy: map[string]any{
|
||||
"enabled": true,
|
||||
"keywords": []any{"rate_limit"},
|
||||
},
|
||||
}
|
||||
|
||||
decision := priorityDemoteDecisionForCandidate(runnerPolicy, &clients.ClientError{Code: "rate_limit", Message: "rate_limit from upstream", Retryable: true})
|
||||
if decision.Demote || decision.Reason != "failover_action_rules_enabled" {
|
||||
t.Fatalf("legacy priority demotion should be skipped when actionRules are active, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,18 +45,29 @@ func (s *Service) applyCandidateFailurePolicies(ctx context.Context, taskID stri
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyFailoverAction(ctx context.Context, taskID string, candidate store.RuntimeModelCandidate, decision failoverDecision, simulated bool, singleSourceProtected bool) {
|
||||
func (s *Service) applyFailoverAction(ctx context.Context, taskID string, candidate store.RuntimeModelCandidate, requestedModel string, decision failoverDecision, simulated bool, singleSourceProtected bool) {
|
||||
switch decision.Action {
|
||||
case "disable_and_next":
|
||||
if singleSourceProtected {
|
||||
s.emitSingleSourceProtected(ctx, taskID, candidate, decision.Action, decision.Info.Code, simulated)
|
||||
return
|
||||
}
|
||||
if err := s.store.DisableCandidatePlatform(ctx, candidate.PlatformID); err == nil {
|
||||
target := decision.Target
|
||||
if target == "" {
|
||||
target = "platform"
|
||||
}
|
||||
var err error
|
||||
if target == "model" {
|
||||
err = s.store.DisableCandidatePlatformModel(ctx, candidate.PlatformModelID)
|
||||
} else {
|
||||
err = s.store.DisableCandidatePlatform(ctx, candidate.PlatformID)
|
||||
}
|
||||
if err == nil {
|
||||
_ = s.emit(ctx, taskID, "task.policy.failover_disabled", "running", "failover", 0.51, "candidate platform disabled by runner failover policy", addPolicyTracePayload(map[string]any{
|
||||
"platformId": candidate.PlatformID,
|
||||
"platformModelId": candidate.PlatformModelID,
|
||||
"action": decision.Action,
|
||||
"target": target,
|
||||
"reason": decision.Reason,
|
||||
}, decision.Match, decision.Info), simulated)
|
||||
}
|
||||
@@ -65,15 +76,43 @@ func (s *Service) applyFailoverAction(ctx context.Context, taskID string, candid
|
||||
s.emitSingleSourceProtected(ctx, taskID, candidate, decision.Action, decision.Info.Code, simulated)
|
||||
return
|
||||
}
|
||||
if err := s.store.CooldownCandidatePlatformModel(ctx, candidate.PlatformModelID, decision.CooldownSeconds); err == nil {
|
||||
target := decision.Target
|
||||
if target == "" {
|
||||
target = "model"
|
||||
}
|
||||
var err error
|
||||
if target == "platform" {
|
||||
err = s.store.CooldownCandidatePlatform(ctx, candidate.PlatformID, decision.CooldownSeconds)
|
||||
} else {
|
||||
err = s.store.CooldownCandidatePlatformModel(ctx, candidate.PlatformModelID, decision.CooldownSeconds)
|
||||
}
|
||||
if err == nil {
|
||||
_ = s.emit(ctx, taskID, "task.policy.failover_cooled_down", "running", "failover", 0.51, "candidate model cooled down by runner failover policy", addPolicyTracePayload(map[string]any{
|
||||
"platformId": candidate.PlatformID,
|
||||
"platformModelId": candidate.PlatformModelID,
|
||||
"cooldownSeconds": decision.CooldownSeconds,
|
||||
"action": decision.Action,
|
||||
"target": target,
|
||||
"reason": decision.Reason,
|
||||
}, decision.Match, decision.Info), simulated)
|
||||
}
|
||||
case "demote_and_next":
|
||||
demoteSteps := decision.DemoteSteps
|
||||
if demoteSteps <= 0 {
|
||||
demoteSteps = 1
|
||||
}
|
||||
if dynamicPriority, err := s.store.DemoteCandidatePlatformPriorityBySteps(ctx, candidate.PlatformID, candidate.PlatformModelID, requestedModel, candidate.ModelType, demoteSteps); err == nil {
|
||||
_ = s.emit(ctx, taskID, "task.policy.priority_demoted", "running", "priority_demote", 0.52, "candidate platform priority demoted by runner failover policy", addPolicyTracePayload(map[string]any{
|
||||
"platformId": candidate.PlatformID,
|
||||
"platformModelId": candidate.PlatformModelID,
|
||||
"dynamicPriority": dynamicPriority,
|
||||
"demoteSteps": demoteSteps,
|
||||
"action": decision.Action,
|
||||
"target": "platform",
|
||||
"reason": decision.Reason,
|
||||
"errorMessage": decision.Info.Message,
|
||||
}, decision.Match, decision.Info), simulated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +133,17 @@ func (s *Service) applyPriorityDemotePolicy(ctx context.Context, taskID string,
|
||||
if !decision.Demote {
|
||||
return
|
||||
}
|
||||
if dynamicPriority, err := s.store.DemoteCandidatePlatformPriority(ctx, candidate.PlatformID, candidate.PlatformModelID, requestedModel, candidate.ModelType); err == nil {
|
||||
demoteSteps := decision.DemoteSteps
|
||||
if demoteSteps <= 0 {
|
||||
demoteSteps = 99
|
||||
}
|
||||
if dynamicPriority, err := s.store.DemoteCandidatePlatformPriorityBySteps(ctx, candidate.PlatformID, candidate.PlatformModelID, requestedModel, candidate.ModelType, demoteSteps); err == nil {
|
||||
s.recordAttemptTrace(ctx, taskID, attemptNo, priorityDemoteTraceEntry(decision, candidate.PlatformID, candidate.PlatformModelID, dynamicPriority))
|
||||
_ = s.emit(ctx, taskID, "task.policy.priority_demoted", "running", "priority_demote", 0.52, "candidate platform priority demoted by runner policy", addPolicyTracePayload(map[string]any{
|
||||
"platformId": candidate.PlatformID,
|
||||
"platformModelId": candidate.PlatformModelID,
|
||||
"dynamicPriority": dynamicPriority,
|
||||
"demoteSteps": demoteSteps,
|
||||
"code": clients.ErrorCode(cause),
|
||||
"reason": decision.Reason,
|
||||
"errorMessage": messageFromError(cause),
|
||||
|
||||
@@ -145,7 +145,15 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user)
|
||||
runnerPolicy, err := s.store.GetActiveRunnerPolicy(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
cacheAffinityKey := buildCacheAffinityKey(task.Kind, modelType, body)
|
||||
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user, store.ListModelCandidatesOptions{
|
||||
CacheAffinityKey: cacheAffinityKey,
|
||||
CacheAffinityPolicy: runnerPolicy.CacheAffinityPolicy,
|
||||
})
|
||||
if err != nil {
|
||||
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||
Task: task,
|
||||
@@ -278,10 +286,6 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
runnerPolicy, err := s.store.GetActiveRunnerPolicy(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
maxPlatforms := maxPlatformsForCandidates(candidates, runnerPolicy)
|
||||
maxFailoverDuration := maxFailoverDurationForCandidates(candidates, runnerPolicy)
|
||||
singleSourceProtected := singleSourceProtectionActive(candidates, maxPlatforms, runnerPolicy)
|
||||
@@ -320,7 +324,7 @@ candidatesLoop:
|
||||
break candidatesLoop
|
||||
}
|
||||
candidateBody := preprocessing.Body
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, singleSourceProtected)
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, nextAttemptNo, onDelta, singleSourceProtected, runnerPolicy.CacheAffinityPolicy)
|
||||
if err == nil {
|
||||
attemptNo = nextAttemptNo
|
||||
billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate))
|
||||
@@ -490,7 +494,7 @@ candidatesLoop:
|
||||
if !decision.Retry {
|
||||
break
|
||||
}
|
||||
s.applyFailoverAction(ctx, task.ID, candidate, decision, isSimulation(task, candidate), singleSourceProtected)
|
||||
s.applyFailoverAction(ctx, task.ID, candidate, task.Model, decision, isSimulation(task, candidate), singleSourceProtected)
|
||||
if err := s.emit(ctx, task.ID, "task.retrying", "running", "retry", 0.55, "retrying next client", addPolicyTracePayload(map[string]any{
|
||||
"attempt": attemptNo,
|
||||
"action": decision.Action,
|
||||
@@ -531,7 +535,7 @@ candidatesLoop:
|
||||
return Result{Task: failed, Output: failed.Result}, lastErr
|
||||
}
|
||||
|
||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta, singleSourceProtected bool) (clients.Response, error) {
|
||||
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, attemptNo int, onDelta clients.StreamDelta, singleSourceProtected bool, cacheAffinityPolicy map[string]any) (clients.Response, error) {
|
||||
simulated := isSimulation(task, candidate)
|
||||
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
|
||||
reservations := s.rateLimitReservations(ctx, user, candidate, body)
|
||||
@@ -763,6 +767,19 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
}); err != nil {
|
||||
return clients.Response{}, fmt.Errorf("finish task attempt: %w", err)
|
||||
}
|
||||
if err := s.store.RecordCacheAffinityObservation(context.WithoutCancel(ctx), store.CacheAffinityObservationInput{
|
||||
CacheAffinityKey: candidate.CacheAffinity.Key,
|
||||
CacheAffinityPolicy: cacheAffinityPolicy,
|
||||
PlatformID: candidate.PlatformID,
|
||||
PlatformModelID: candidate.PlatformModelID,
|
||||
ClientID: candidate.ClientID,
|
||||
ModelType: candidate.ModelType,
|
||||
InputTokens: response.Usage.InputTokens,
|
||||
CachedInputTokens: response.Usage.CachedInputTokens,
|
||||
CachedInputTokensKnown: response.Usage.CachedInputTokensKnown,
|
||||
}); err != nil {
|
||||
s.logger.Warn("record cache affinity observation failed", "error", err, "clientId", candidate.ClientID)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,21 @@ func failoverTraceEntry(decision failoverDecision, candidate store.RuntimeModelC
|
||||
if decision.CooldownSeconds > 0 {
|
||||
entry["cooldownSeconds"] = decision.CooldownSeconds
|
||||
}
|
||||
if decision.Target != "" {
|
||||
entry["target"] = decision.Target
|
||||
}
|
||||
if decision.DemoteSteps > 0 {
|
||||
entry["demoteSteps"] = decision.DemoteSteps
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func priorityDemoteTraceEntry(decision priorityDemoteDecision, platformID string, platformModelID string, dynamicPriority int) map[string]any {
|
||||
entry := policyTraceEntry("priority_demoted", "priority_demote", "demote", decision.Reason, decision.Match, decision.Info)
|
||||
entry["demote"] = decision.Demote
|
||||
if decision.DemoteSteps > 0 {
|
||||
entry["demoteSteps"] = decision.DemoteSteps
|
||||
}
|
||||
if dynamicPriority > 0 {
|
||||
entry["dynamicPriority"] = dynamicPriority
|
||||
}
|
||||
@@ -78,6 +87,16 @@ func addCandidatePriorityTraceFields(entry map[string]any, candidate store.Runti
|
||||
if candidate.PlatformName != "" {
|
||||
entry["currentPlatformName"] = candidate.PlatformName
|
||||
}
|
||||
if candidate.CacheAffinity.Key != "" {
|
||||
entry["cacheAffinityKey"] = candidate.CacheAffinity.Key
|
||||
entry["cacheAffinityScore"] = candidate.CacheAffinity.Score
|
||||
entry["cacheAffinityConfidence"] = candidate.CacheAffinity.Confidence
|
||||
entry["cacheAffinityHitRatio"] = candidate.CacheAffinity.EMAHitRatio
|
||||
entry["cacheAffinityLastHitRatio"] = candidate.CacheAffinity.LastHitRatio
|
||||
entry["cacheAffinityCachedInputTokens"] = candidate.CacheAffinity.CachedInputTokens
|
||||
entry["cacheAffinityBoost"] = candidate.CacheAffinity.Boost
|
||||
entry["cacheAffinityApplied"] = candidate.CacheAffinity.Applied
|
||||
}
|
||||
}
|
||||
|
||||
func policyTraceEntry(event string, scope string, action string, reason string, match policyRuleMatch, info failureInfo) map[string]any {
|
||||
|
||||
Reference in New Issue
Block a user