Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 33 additions & 33 deletions core/controller/relay-backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func TestInitialChannelSelectionPrefersOrdinaryAndAllowsBackupOnlyPool(t *testin
ignored map[int64]struct{}
wantBackup bool
}{
{name: "preferred backup stays excluded while primary is eligible", channels: []*model.Channel{primary, backup}},
{name: "preferred backup is eligible alongside primary", channels: []*model.Channel{primary, backup}, wantBackup: true},
{
name: "high error primary unlocks backup",
channels: []*model.Channel{primary, backup}, errorRates: map[int64]float64{1: 1}, wantBackup: true,
Expand All @@ -50,7 +50,7 @@ func TestInitialChannelSelectionPrefersOrdinaryAndAllowsBackupOnlyPool(t *testin
{name: "backup alone can start a request", channels: []*model.Channel{backup}, wantBackup: true},
{
name: "primary at error threshold remains eligible", channels: []*model.Channel{primary, backup},
errorRates: map[int64]float64{1: maxRetryErrorRate},
errorRates: map[int64]float64{1: maxRetryErrorRate}, wantBackup: true,
},
{
name: "unhealthy backup does not replace primary fallback", channels: []*model.Channel{primary, backup},
Expand Down Expand Up @@ -92,7 +92,7 @@ func TestInitialChannelSelectionPrefersOrdinaryAndAllowsBackupOnlyPool(t *testin
}
}

func TestInitialBackupUsesPreferencesAndStaysUnlockedAfterPrimaryRecovery(t *testing.T) {
func TestInitialPreferredBackupKeepsGeneralBackupsLocked(t *testing.T) {
t.Parallel()

primary := &model.Channel{
Expand Down Expand Up @@ -135,7 +135,7 @@ func TestInitialBackupUsesPreferencesAndStaysUnlockedAfterPrimaryRecovery(t *tes
},
model.Price{}, time.Now(),
)
assert.True(t, state.backupOnlyEnabled)
assert.False(t, state.backupOnlyEnabled)
assert.ElementsMatch(
t,
[]*model.Channel{primary, backup, generalPrimary, generalBackup},
Expand All @@ -154,10 +154,13 @@ func TestInitialBackupUsesPreferencesAndStaysUnlockedAfterPrimaryRecovery(t *tes
[]*model.Channel{generalPrimary, generalBackup},
getRetryCandidates(state, nil),
)
assert.True(t, state.backupOnlyEnabled)
assert.False(t, state.backupOnlyEnabled)
channel, err = getRetryChannel(context.Background(), state)
require.NoError(t, err)
assert.Equal(t, generalPrimary, channel)
}

func TestOrdinarySelectionCompletesBeforeRestartingWithBackups(t *testing.T) {
func TestPreferencesIncludeBackupsBeforeRestartingRound(t *testing.T) {
t.Parallel()

channels := []*model.Channel{
Expand Down Expand Up @@ -185,7 +188,7 @@ func TestOrdinarySelectionCompletesBeforeRestartingWithBackups(t *testing.T) {
mc, nil, "backup-test", mode.Responses, []int{2, 1, 3, 4}, nil, nil,
)
require.NoError(t, err)
require.Equal(t, 1, initial.channel.ID)
require.Equal(t, 2, initial.channel.ID)
assert.False(t, initial.backupOnlyEnabled)

state := initRetryState(
Expand All @@ -201,15 +204,11 @@ func TestOrdinarySelectionCompletesBeforeRestartingWithBackups(t *testing.T) {
time.Now(),
)

for i, id := range []int{3, 2, 1, 3, 4} {
for _, id := range []int{1, 3, 4} {
channel, err := getRetryChannel(context.Background(), state)
require.NoError(t, err)
require.Equal(t, id, channel.ID)
assert.Equal(t, i != 0, state.backupOnlyEnabled)

if i == 1 {
assert.Empty(t, state.failedChannelIDs)
}
assert.False(t, state.backupOnlyEnabled)

state.failedChannelIDs[int64(id)] = struct{}{}
}
Expand All @@ -232,14 +231,6 @@ func TestBackupUnlockRestartsRoundWithOrdinaryChannels(t *testing.T) {
priorities [2]int32
wantIDs []int
}{
{
name: "preferred primary retries before backup", preferred: []int{1, 2},
priorities: [2]int32{-1, 10}, wantIDs: []int{1, 1, 2},
},
{
name: "preferred backup retries before primary", preferred: []int{2, 1},
priorities: [2]int32{10, -1}, wantIDs: []int{1, 2, 1},
},
{
name: "weighted primary retries before backup",
priorities: [2]int32{10, -1}, wantIDs: []int{1, 1, 2},
Expand Down Expand Up @@ -356,14 +347,14 @@ func TestPreferredBackupEligibility(t *testing.T) {
wantIDs []int
wantUnlocked bool
}{
{name: "preferred backup waits for all ordinary channels", preferred: []int{2}, wantIDs: []int{1, 3}},
{name: "preferred backup precedes ordinary channels", preferred: []int{2}, wantIDs: []int{2}},
{
name: "preferred primary unhealthy", preferred: []int{1, 2},
errorRates: map[int64]float64{1: 1}, wantIDs: []int{3},
errorRates: map[int64]float64{1: 1}, wantIDs: []int{2},
},
{
name: "preferred primary banned", preferred: []int{1, 2},
ignored: map[int64]struct{}{1: {}}, wantIDs: []int{3},
ignored: map[int64]struct{}{1: {}}, wantIDs: []int{2},
},
{
name: "preferred backup unhealthy", preferred: []int{1, 2},
Expand All @@ -375,15 +366,15 @@ func TestPreferredBackupEligibility(t *testing.T) {
},
{
name: "preferred backup at error threshold", preferred: []int{1, 2},
errorRates: map[int64]float64{1: 1, 2: maxRetryErrorRate, 3: 1}, wantIDs: []int{2}, wantUnlocked: true,
errorRates: map[int64]float64{1: 1, 2: maxRetryErrorRate, 3: 1}, wantIDs: []int{2},
},
{
name: "all primaries unhealthy restores preferred backup", preferred: []int{2},
errorRates: map[int64]float64{1: 1, 3: 1}, wantIDs: []int{2}, wantUnlocked: true,
errorRates: map[int64]float64{1: 1, 3: 1}, wantIDs: []int{2},
},
{
name: "all primaries banned restores preferred backup", preferred: []int{2},
ignored: map[int64]struct{}{1: {}, 3: {}}, wantIDs: []int{2}, wantUnlocked: true,
ignored: map[int64]struct{}{1: {}, 3: {}}, wantIDs: []int{2},
},
{
name: "preferred backup banned after unlock", preferred: []int{2},
Expand Down Expand Up @@ -548,7 +539,7 @@ func TestBackupOnlyUnlocksAfterPrimaryFailuresAndSurvivesRounds(t *testing.T) {
{ID: 4, Status: model.ChannelStatusEnabled, BackupOnly: true},
}
initial := &initialChannel{
channel: channels[0], migratedChannels: channels, preferChannelIDs: []int{3, 4, 2},
channel: channels[0], migratedChannels: channels, preferChannelIDs: []int{2},
}
state := initRetryState(
10, initial, meta.NewMeta(channels[0], mode.Responses, "backup-test", model.ModelConfig{}),
Expand All @@ -563,18 +554,27 @@ func TestBackupOnlyUnlocksAfterPrimaryFailuresAndSurvivesRounds(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 2, channel.ID)
assert.False(t, state.backupOnlyEnabled)
assert.Equal(t, []int{3, 4, 2}, state.preferChannelIDs)
assert.Equal(t, []int{2}, state.preferChannelIDs)
state.failedChannelIDs[2] = struct{}{}

for _, id := range []int{3, 4, 2, 1} {
ids := make([]int, 0, 4)
for i := range 4 {
channel, err = getRetryChannel(context.Background(), state)
require.NoError(t, err)
assert.Equal(t, id, channel.ID)

if i == 0 {
assert.Equal(t, 2, channel.ID)
}

ids = append(ids, channel.ID)

assert.True(t, state.backupOnlyEnabled)
assert.Equal(t, []int{3, 4, 2}, state.preferChannelIDs)
state.failedChannelIDs[int64(id)] = struct{}{}
assert.Equal(t, []int{2}, state.preferChannelIDs)
state.failedChannelIDs[int64(channel.ID)] = struct{}{}
}

assert.ElementsMatch(t, []int{1, 2, 3, 4}, ids)

channel, err = getRetryChannel(context.Background(), state)
require.NoError(t, err)
assert.Contains(t, channels, channel)
Expand Down
1 change: 1 addition & 0 deletions core/controller/relay-budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func TestRetryLoopBudgetAndCount(t *testing.T) {
}

if tt.initialBackoff {
state.preferChannelIDs = []int{1}
state.recordChannelFailure(1, time.Now())
}

Expand Down
15 changes: 9 additions & 6 deletions core/controller/relay-channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,23 +357,22 @@ type channelSelectionState struct {
backupOnlyEnabled bool // Remains enabled across retry rounds for this request.
}

// Channels have already passed health and failure filtering. Both selection
// passes reuse that snapshot and restart from the same preference order.
// Preferences bypass backup-only gating, while health and failure filters still apply.
func (s *channelSelectionState) selectChannel(
channels []*model.Channel,
preferChannelIDs []int,
errorRates map[int64]float64,
) (*model.Channel, error) {
if channel := pickPreferredChannel(channels, preferChannelIDs); channel != nil {
return channel, nil
}

for {
candidates := channels
if !s.backupOnlyEnabled {
candidates = nonBackupChannels(channels)
}

if channel := pickPreferredChannel(candidates, preferChannelIDs); channel != nil {
return channel, nil
}

channel, err := pickChannel(candidates, errorRates)
if err == nil || s.backupOnlyEnabled {
return channel, err
Expand Down Expand Up @@ -697,6 +696,10 @@ func (s *retryState) selectRetryChannel(
errorRates map[int64]float64,
) (*model.Channel, error) {
candidates := getRetryCandidates(s, errorRates)
if channel := pickPreferredChannel(candidates, s.preferChannelIDs); channel != nil {
return channel, nil
}

if !s.backupOnlyEnabled && len(candidates) > 0 && len(nonBackupChannels(candidates)) == 0 {
// Adding eligible backups starts a fresh round while preserving cache preferences.
s.backupOnlyEnabled = true
Expand Down
2 changes: 2 additions & 0 deletions core/relay/meta/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type ChannelMeta struct {
Key string
ID int
Type model.ChannelType
BackupOnly bool
ModelMapping map[string]string
EnabledAutoBalanceCheck bool
SkipTLSVerify bool
Expand Down Expand Up @@ -196,6 +197,7 @@ func (m *Meta) SetChannel(channel *model.Channel) {
m.Channel.Key = channel.Key
m.Channel.ID = channel.ID
m.Channel.Type = channel.Type
m.Channel.BackupOnly = channel.BackupOnly
m.Channel.EnabledAutoBalanceCheck = channel.EnabledAutoBalanceCheck
m.Channel.SkipTLSVerify = channel.SkipTLSVerify
m.Channel.EnabledNoPermissionBan = channel.EnabledNoPermissionBan
Expand Down
16 changes: 11 additions & 5 deletions core/relay/plugin/cachefollow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Default behavior, with only `prompt_cache_key` and `user` scopes enabled:
"cachefollow": {
"enable": true,
"followed_channel_ttl_seconds": 180,
"recent_channel_update_debounce_seconds": 30
"recent_channel_update_debounce_seconds": 45
}
}
}
Expand All @@ -42,7 +42,7 @@ If you also want generic cache-follow:
"enable": true,
"enable_generic_follow": true,
"followed_channel_ttl_seconds": 180,
"recent_channel_update_debounce_seconds": 30
"recent_channel_update_debounce_seconds": 45
}
}
}
Expand All @@ -55,7 +55,9 @@ If you also want generic cache-follow:
| `enable` | `bool` | `false` | Supports `prompt_cache_key`-based targeted channel follow and user-scoped cache-follow by default, helping improve upstream cache hit rate. Disabled by default. |
| `enable_generic_follow` | `bool` | `false` | Enables generic cache-follow for the model-level scope. This is only used when no `prompt_cache_key` mapping or user-scoped mapping is available. Recommended when each user effectively has an isolated `group` and `token`; not recommended when many users share the same `group` and `token` scope. |
| `followed_channel_ttl_seconds` | `integer` | `180` | Controls how long a remembered cache-effective channel stays valid. It always applies to user-scoped entries, applies to generic entries only when generic follow is enabled, and also applies to `prompt_cache_key` entries when the upstream does not return a more specific retention. |
| `recent_channel_update_debounce_seconds` | `integer` | `30` | Controls the minimum refresh interval for the `recent` channel mapping in the same scope, reducing noisy `recent` updates while still following recent upstream routing changes. |
| `recent_channel_update_debounce_seconds` | `integer` | `45` | Controls the minimum refresh interval for the `recent` channel mapping in the same scope, reducing noisy `recent` updates while still following recent upstream routing changes. |

Every new mapping has a maximum TTL of `300s`, including configured TTLs and upstream retention such as `24h`. Further successful cache usage can refresh `recent` after the debounce interval, starting a new bounded TTL.

## Scopes and Priority

Expand Down Expand Up @@ -102,6 +104,7 @@ They are written differently:

- `stable` is only written when that scope does not already have a stable mapping
- `recent` can be refreshed continuously, but is rate-limited by `recent_channel_update_debounce_seconds`
- backup-only channels only write `recent` mappings

In practice:

Expand Down Expand Up @@ -160,7 +163,7 @@ When the request includes `prompt_cache_key` and the request has cache-related u

TTL rules for this scope:

- if the upstream returns a valid `prompt_cache_retention`, that value is used first
- if the upstream returns a valid `prompt_cache_retention`, it is capped at 5 minutes
- otherwise `followed_channel_ttl_seconds` is used
- if `followed_channel_ttl_seconds` is not configured, the built-in default `180s` is used

Expand Down Expand Up @@ -248,13 +251,15 @@ This means:

Remembered channels are only preferences. They still go through normal availability checks.

A preferred `backup_only` channel is eligible immediately, in preference order. Selecting it does not unlock other backup channels. Channels that failed in the current retry round remain excluded until the round resets.

A preferred channel is skipped if any of the following is true:

- it no longer exists in the model's currently available channel set
- it is disabled
- it does not support the current request mode
- it is currently banned by monitor
- its error rate is higher than `0.75`
- its error rate is higher than `0.85`

If a preferred channel is skipped, the system moves to the next preferred channel. If no preferred channel survives filtering, selection falls back to the normal channel selection flow.

Expand All @@ -271,3 +276,4 @@ This also means:
- generic mappings are only read and written when `enable_generic_follow = true`
- the plugin only affects channel preference; it does not modify the request body
- `recent_channel_update_debounce_seconds` only affects `recent` refresh frequency and does not affect `stable`
- newly written cache mappings never exceed 5 minutes, regardless of upstream retention
9 changes: 6 additions & 3 deletions core/relay/plugin/cachefollow/cachefollow.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import (
)

const (
maxFollowedChannelTTL = 5 * time.Minute
defaultFollowedChannelTTL = 3 * time.Minute
defaultRecentChannelUpdateDebounce = 30 * time.Second
defaultRecentChannelUpdateDebounce = 45 * time.Second
)

var _ plugin.Plugin = (*Plugin)(nil)
Expand All @@ -44,6 +45,8 @@ func (p *Plugin) getConfig(meta *meta.Meta) (*Config, error) {
}

func getFollowedChannelTTL(retention string, defaultTTL time.Duration) time.Duration {
defaultTTL = min(defaultTTL, maxFollowedChannelTTL)

retention = strings.TrimSpace(strings.ToLower(retention))
if retention == "" || retention == "in-memory" || retention == "in_memory" {
return defaultTTL
Expand All @@ -54,7 +57,7 @@ func getFollowedChannelTTL(retention string, defaultTTL time.Duration) time.Dura
return defaultTTL
}

return ttl
return min(ttl, maxFollowedChannelTTL)
}

func getNodeStringField(node *ast.Node, key string) (string, bool) {
Expand Down Expand Up @@ -154,7 +157,7 @@ func saveStableStoreMapping(
meta *meta.Meta,
expiresAt time.Time,
) error {
if id == "" {
if id == "" || meta.Channel.BackupOnly {
return nil
}

Expand Down
Loading
Loading