feat: add runner failover rules and cache affinity

This commit is contained in:
2026-06-28 20:50:23 +08:00
parent 41bb3a6525
commit 0e675e259c
31 changed files with 2939 additions and 470 deletions
@@ -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)
}
}
+29
View File
@@ -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{
+1 -1
View File
@@ -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{}
+15 -1
View File
@@ -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)
}
}
+141 -14
View File
@@ -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)
}
}
+48 -4
View File
@@ -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),
+25 -8
View File
@@ -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
}
+19
View File
@@ -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 {