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
15 changes: 8 additions & 7 deletions middleware/distributor.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,32 @@ func Distribute() func(c *gin.Context) {
}

if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil {
if preferred.Status != common.ChannelStatusEnabled {
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled))
return
}
} else if usingGroup == "auto" {
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
affinityUsable = true
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
affinityUsable = true
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
if !affinityUsable && !service.ShouldKeepChannelAffinityOnChannelDisabled() {
service.ClearCurrentChannelAffinityCache(c)
}
}

if channel == nil {
Expand Down
32 changes: 32 additions & 0 deletions service/channel_affinity.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,38 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
return meta.SkipRetry
}

func ClearCurrentChannelAffinityCache(c *gin.Context) bool {
if c == nil {
return false
}
cacheKey, _, ok := getChannelAffinityContext(c)
if !ok || cacheKey == "" {
return false
}

cache := getChannelAffinityCache()
deleted, err := cache.DeleteMany([]string{cacheKey})
if err != nil {
common.SysError(fmt.Sprintf("channel affinity cache delete current failed: err=%v", err))
return false
}
c.Set(ginKeyChannelAffinitySkipRetry, false)
Comment on lines +654 to +659

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset retry-skip state even when cache deletion errors.

On Line 655, the early return on DeleteMany error skips resetting ginKeyChannelAffinitySkipRetry. That can keep retries disabled for this request (shouldRetry exits early when the skip flag is true), which weakens fallback resilience on transient cache failures.

💡 Suggested fix
 func ClearCurrentChannelAffinityCache(c *gin.Context) bool {
 	if c == nil {
 		return false
 	}
 	cacheKey, _, ok := getChannelAffinityContext(c)
 	if !ok || cacheKey == "" {
 		return false
 	}
 
+	// Always clear request-level skip-retry state for this request path.
+	c.Set(ginKeyChannelAffinitySkipRetry, false)
+
 	cache := getChannelAffinityCache()
 	deleted, err := cache.DeleteMany([]string{cacheKey})
 	if err != nil {
 		common.SysError(fmt.Sprintf("channel affinity cache delete current failed: err=%v", err))
 		return false
 	}
-	c.Set(ginKeyChannelAffinitySkipRetry, false)
 	for _, ok := range deleted {
 		if ok {
 			return true
 		}
 	}
 	return false
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_affinity.go` around lines 654 - 659, The early return after
cache.DeleteMany leaves ginKeyChannelAffinitySkipRetry set and prevents retries;
ensure c.Set(ginKeyChannelAffinitySkipRetry, false) always runs even if
cache.DeleteMany returns an error by moving the reset before the error check or,
better, use a defer right after obtaining the context (so the flag is reset
unconditionally). Update the block around cache.DeleteMany (referencing
DeleteMany, ginKeyChannelAffinitySkipRetry, c.Set and shouldRetry) to guarantee
the flag is cleared on both success and error paths.

for _, ok := range deleted {
if ok {
return true
}
}
return false
}

func ShouldKeepChannelAffinityOnChannelDisabled() bool {
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil {
return false
}
return setting.KeepOnChannelDisabled
}

func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
if c == nil || channelID <= 0 {
return
Expand Down
27 changes: 27 additions & 0 deletions service/channel_affinity_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,33 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
}

func TestClearCurrentChannelAffinityCache(t *testing.T) {
gin.SetMode(gin.TestMode)

cacheKeySuffix := fmt.Sprintf("codex cli trace:default:clear-current-%d", time.Now().UnixNano())
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})

ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
CacheKey: cacheKeyFull,
TTLSeconds: 60,
RuleName: "codex cli trace",
SkipRetry: true,
})
require.True(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))

deleted := ClearCurrentChannelAffinityCache(ctx)
require.True(t, deleted)
_, found, err := cache.Get(cacheKeySuffix)
require.NoError(t, err)
require.False(t, found)
require.False(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
}

func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
gin.SetMode(gin.TestMode)

Expand Down
20 changes: 11 additions & 9 deletions setting/operation_setting/channel_affinity_setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ type ChannelAffinityRule struct {
}

type ChannelAffinitySetting struct {
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
KeepOnChannelDisabled bool `json:"keep_on_channel_disabled"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
}

var codexCliPassThroughHeaders = []string{
Expand Down Expand Up @@ -74,10 +75,11 @@ func buildPassHeaderTemplate(headers []string) map[string]interface{} {
}

var channelAffinitySetting = ChannelAffinitySetting{
Enabled: true,
SwitchOnSuccess: true,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Enabled: true,
SwitchOnSuccess: true,
KeepOnChannelDisabled: false,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{
{
Name: "codex cli trace",
Expand Down
2 changes: 2 additions & 0 deletions web/classic/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,7 @@
"套餐的基本信息和定价": "Basic plan info and pricing",
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
"如:香港线路": "e.g. Hong Kong line",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. When disabled, the entry will be deleted and another channel will be selected.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "If the affinity channel fails, after a successful retry on another channel, the affinity will be updated to the successful channel.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "If you are connecting to upstream One API or New API forwarding projects, please use OpenAI type. Do not use this type unless you know what you are doing.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt",
Expand Down Expand Up @@ -1579,6 +1580,7 @@
"成功": "Success",
"成功兑换额度:": "Successful redemption amount:",
"成功后切换亲和": "Switch Affinity on Success",
"渠道禁用后保留亲和": "Keep Affinity When Channel Is Disabled",
"成功时自动启用通道": "Enable channel when successful",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "I have understood that disabling two-factor authentication will permanently delete all related settings and backup codes, this operation cannot be undone",
"我已阅读并同意": "I have read and agree to",
Expand Down
2 changes: 2 additions & 0 deletions web/classic/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,7 @@
"套餐的基本信息和定价": "Informations de base et tarification du plan",
"如:大带宽批量分析图片推荐": "par exemple, Recommandations d'analyse d'images par lots à large bande passante",
"如:香港线路": "par exemple, Ligne de Hong Kong",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Lorsque cette option est activée, conserver l'entrée d'affinité même si le canal d'affinité est désactivé ou n'est plus utilisable pour le groupe/modèle actuel. Lorsqu'elle est désactivée, l'entrée sera supprimée et un autre canal sera sélectionné.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Si le canal d'affinité échoue, après une nouvelle tentative réussie sur un autre canal, l'affinité sera mise à jour vers le canal réussi.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Si vous vous connectez à des projets de redirection One API ou New API en amont, veuillez utiliser le type OpenAI. N'utilisez pas ce type, sauf si vous savez ce que vous faites.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Si la requête de l'utilisateur contient un prompt système, utilisez ce paramètre pour le concaténer avant le prompt système de l'utilisateur",
Expand Down Expand Up @@ -1584,6 +1585,7 @@
"成功": "Succès",
"成功兑换额度:": "Montant de l'échange réussi :",
"成功后切换亲和": "Changer l'affinité en cas de succès",
"渠道禁用后保留亲和": "Conserver l'affinité lorsque le canal est désactivé",
"成功时自动启用通道": "Activer le canal en cas de succès",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "J'ai compris que la désactivation de l'authentification à deux facteurs supprimera définitivement tous les paramètres et codes de sauvegarde associés, cette opération ne peut pas être annulée",
"我已阅读并同意": "J'ai lu et j'accepte",
Expand Down
2 changes: 2 additions & 0 deletions web/classic/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,7 @@
"套餐的基本信息和定价": "プランの基本情報と価格",
"如:大带宽批量分析图片推荐": "例:広帯域での画像一括分析に推奨",
"如:香港线路": "例:香港回線",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効にすると、エントリを削除して別のチャネルを選択します。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "アフィニティチャネルが失敗した場合、別のチャネルでリトライが成功すると、アフィニティが成功したチャネルに更新されます。",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "New APIなどのリレープロジェクトに接続する場合は、OpenAIタイプを利用してください。設定内容を熟知している場合を除き、このタイプは利用しないでください",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "ユーザーリクエストにシステムプロンプトが含まれている場合、この設定内容がユーザーのシステムプロンプトの前に追加されます",
Expand Down Expand Up @@ -1555,6 +1556,7 @@
"成功": "成功",
"成功兑换额度:": "引き換え額:",
"成功后切换亲和": "成功時にアフィニティを切り替え",
"渠道禁用后保留亲和": "チャネル無効時にアフィニティを保持",
"成功时自动启用通道": "成功時にチャネルを自動的に有効にする",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "2要素認証を無効にすると、すべての関連設定とバックアップコードが永久に削除され、この操作は元に戻すことができないことを理解しました",
"我已阅读并同意": "読んで同意します",
Expand Down
2 changes: 2 additions & 0 deletions web/classic/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,7 @@
"套餐的基本信息和定价": "Основная информация и цена плана",
"如:大带宽批量分析图片推荐": "Например: рекомендуется для пакетного анализа изображений с большой пропускной способностью",
"如:香港线路": "Например: Гонконгская линия",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Если включено, запись аффинити сохраняется, даже когда канал аффинити отключён или больше не подходит для текущей группы/модели. Если выключено, запись будет удалена и выбран другой канал.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Если канал аффинити не сработал, после успешного повтора на другом канале аффинити будет обновлена на успешный канал.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Если вы интегрируетесь с восходящими проектами пересылки, такими как One API или New API, используйте тип OpenAI, не используйте этот тип, если вы не знаете, что делаете.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Если запрос пользователя содержит системный промпт, используйте эту настройку для добавления перед системным промптом пользователя",
Expand Down Expand Up @@ -1602,6 +1603,7 @@
"成功": "Успешно",
"成功兑换额度:": "Успешно обменяно квота: ",
"成功后切换亲和": "Переключить аффинити при успехе",
"渠道禁用后保留亲和": "Сохранять аффинити при отключении канала",
"成功时自动启用通道": "Автоматически включать канал при успехе",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Я понимаю, что отключение двухфакторной аутентификации приведет к постоянному удалению всех связанных настроек и резервных кодов, и эта операция не может быть отменена",
"我已阅读并同意": "Я прочитал(а) и согласен(на)",
Expand Down
2 changes: 2 additions & 0 deletions web/classic/src/i18n/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,7 @@
"套餐的基本信息和定价": "Thông tin cơ bản và giá của gói",
"如:大带宽批量分析图片推荐": "ví dụ: Phân tích hàng loạt băng thông lớn đề xuất hình ảnh",
"如:香港线路": "ví dụ: Tuyến Hồng Kông",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Khi bật, giữ mục ưu ái ngay cả khi kênh ưu ái bị tắt hoặc không còn dùng được cho nhóm/mô hình hiện tại. Khi tắt, mục đó sẽ bị xóa và kênh khác sẽ được chọn.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Nếu kênh ưu ái thất bại, sau khi thử lại thành công trên kênh khác, ưu ái sẽ được cập nhật sang kênh thành công.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Nếu bạn đang kết nối với các dự án chuyển tiếp One API hoặc New API thượng nguồn, vui lòng sử dụng loại OpenAI. Đừng sử dụng loại này trừ khi bạn biết mình đang làm gì.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Nếu yêu cầu của người dùng chứa từ nhắc hệ thống, cài đặt này sẽ được nối vào trước từ nhắc hệ thống của người dùng",
Expand Down Expand Up @@ -1556,6 +1557,7 @@
"成功": "Thành công",
"成功兑换额度:": "Số tiền đổi thành công:",
"成功后切换亲和": "Chuyển ưu ái khi thành công",
"渠道禁用后保留亲和": "Giữ ưu ái khi kênh bị tắt",
"成功时自动启用通道": "Bật kênh khi thành công",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Tôi đã hiểu rằng việc vô hiệu hóa xác thực hai yếu tố sẽ xóa vĩnh viễn tất cả các cài đặt liên quan và mã dự phòng, thao tác này không thể hoàn tác",
"我已阅读并同意": "Tôi đã đọc và đồng ý với",
Expand Down
2 changes: 2 additions & 0 deletions web/classic/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@
"套餐的基本信息和定价": "套餐的基本信息和定价",
"如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐",
"如:香港线路": "如:香港线路",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面",
Expand Down Expand Up @@ -1541,6 +1542,7 @@
"成功": "成功",
"成功兑换额度:": "成功兑换额度:",
"成功后切换亲和": "成功后切换亲和",
"渠道禁用后保留亲和": "渠道禁用后保留亲和",
"成功时自动启用通道": "成功时自动启用通道",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销",
"我已阅读并同意": "我已阅读并同意",
Expand Down
Loading