Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ea256e8
1. 新增批量导入功能, 支持文件导入, 以换行,逗号格式导入
showyourlucky Feb 21, 2026
27459d1
feat(channel): 为通道添加多Key重试和自动禁用功能
showyourlucky Feb 23, 2026
868ab9a
补全多语言文件
showyourlucky Feb 23, 2026
d22f2f5
解决导入密钥时, 数量显示问题
showyourlucky Feb 23, 2026
62c978c
为分组编辑页面的“已选模型”列表添加了“置顶”和“置底”功能
showyourlucky Feb 23, 2026
bd4a8bf
补全多语言文件
showyourlucky Feb 23, 2026
eeba2c1
注释docker构建内容
showyourlucky Feb 23, 2026
c0ada74
**主要功能 :新增模型选择器组件,支持批量操作**
showyourlucky Feb 23, 2026
4ca269b
Merge branch 'dev' of github.com:showyourlucky/octopus into dev
showyourlucky Feb 24, 2026
82af92c
更新checkbox包
showyourlucky Feb 24, 2026
ab902d6
更新checkbox包
showyourlucky Feb 24, 2026
f89a3f1
同步多语言文件
showyourlucky Feb 24, 2026
6d60651
**功能:新增全选功能,优化批量导入界面交互**
showyourlucky Feb 25, 2026
0ec068f
Merge branch 'bestruirui:dev' into dev
showyourlucky Feb 25, 2026
b5c94e2
Merge branch 'dev' of github.com:showyourlucky/octopus into dev
showyourlucky Feb 25, 2026
9e5c849
移除批量导入服务
showyourlucky Feb 25, 2026
505b1d5
Revert "注释docker构建内容"
showyourlucky Feb 25, 2026
99da044
移除多余的国际化字段
showyourlucky Feb 25, 2026
45425c8
渠道api key管理新增移除和导出功能
showyourlucky Feb 25, 2026
77301ca
Revert "**主要功能 :新增模型选择器组件,支持批量操作**"
showyourlucky Feb 26, 2026
5914477
修正回滚解决冲突的错误
showyourlucky Feb 26, 2026
ce7762c
1. 删除多余代码
showyourlucky Feb 26, 2026
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
588 changes: 588 additions & 0 deletions docs/channel-auto-group-and-sync.md

Large diffs are not rendered by default.

81 changes: 65 additions & 16 deletions internal/model/channel.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package model

import (
"math/rand/v2"
"sort"
"time"

"github.com/bestruirui/octopus/internal/transformer/outbound"
Expand All @@ -16,22 +18,26 @@ const (
)

type Channel struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"unique;not null"`
Type outbound.OutboundType `json:"type"`
Enabled bool `json:"enabled" gorm:"default:true"`
BaseUrls []BaseUrl `json:"base_urls" gorm:"serializer:json"`
Keys []ChannelKey `json:"keys" gorm:"foreignKey:ChannelID"`
Model string `json:"model"`
CustomModel string `json:"custom_model"`
Proxy bool `json:"proxy" gorm:"default:false"`
AutoSync bool `json:"auto_sync" gorm:"default:false"`
AutoGroup AutoGroupType `json:"auto_group" gorm:"default:0"`
CustomHeader []CustomHeader `json:"custom_header" gorm:"serializer:json"`
ParamOverride *string `json:"param_override"`
ChannelProxy *string `json:"channel_proxy"`
Stats *StatsChannel `json:"stats,omitempty" gorm:"foreignKey:ChannelID"`
MatchRegex *string `json:"match_regex"`
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"unique;not null"`
Type outbound.OutboundType `json:"type"`
Enabled bool `json:"enabled" gorm:"default:true"`
BaseUrls []BaseUrl `json:"base_urls" gorm:"serializer:json"`
Keys []ChannelKey `json:"keys" gorm:"foreignKey:ChannelID"`
Model string `json:"model"`
CustomModel string `json:"custom_model"`
Proxy bool `json:"proxy" gorm:"default:false"`
AutoSync bool `json:"auto_sync" gorm:"default:false"`
AutoGroup AutoGroupType `json:"auto_group" gorm:"default:0"`
CustomHeader []CustomHeader `json:"custom_header" gorm:"serializer:json"`
ParamOverride *string `json:"param_override"`
ChannelProxy *string `json:"channel_proxy"`
Stats *StatsChannel `json:"stats,omitempty" gorm:"foreignKey:ChannelID"`
MatchRegex *string `json:"match_regex"`
EnableMultiKeyRetry bool `json:"enable_multi_key_retry" gorm:"default:false"`
RetryCount int `json:"retry_count" gorm:"default:3"`
KeyLoadBalanceMode string `json:"key_load_balance_mode" gorm:"default:'round_robin'"`
AutoBanKeyFailures int `json:"auto_ban_key_failures" gorm:"default:0"` // 0 means disabled
}

type BaseUrl struct {
Expand Down Expand Up @@ -72,6 +78,11 @@ type ChannelUpdateRequest struct {
ParamOverride *string `json:"param_override,omitempty"`
MatchRegex *string `json:"match_regex,omitempty"`

EnableMultiKeyRetry *bool `json:"enable_multi_key_retry,omitempty"`
RetryCount *int `json:"retry_count,omitempty"`
KeyLoadBalanceMode *string `json:"key_load_balance_mode,omitempty"`
AutoBanKeyFailures *int `json:"auto_ban_key_failures,omitempty"`

KeysToAdd []ChannelKeyAddRequest `json:"keys_to_add,omitempty"`
KeysToUpdate []ChannelKeyUpdateRequest `json:"keys_to_update,omitempty"`
KeysToDelete []int `json:"keys_to_delete,omitempty"`
Expand Down Expand Up @@ -153,3 +164,41 @@ func (c *Channel) GetChannelKey() ChannelKey {
}
return best
}

func (c *Channel) GetCandidateKeys() []ChannelKey {
if c == nil || len(c.Keys) == 0 {
return nil
}

nowSec := time.Now().Unix()
var candidates []ChannelKey

for _, k := range c.Keys {
if !k.Enabled || k.ChannelKey == "" {
continue
}
if k.StatusCode == 429 && k.LastUseTimeStamp > 0 {
if nowSec-k.LastUseTimeStamp < int64(5*time.Minute/time.Second) {
continue
}
}
candidates = append(candidates, k)
}

if len(candidates) == 0 {
return nil
}

if c.KeyLoadBalanceMode == "random" {
rand.Shuffle(len(candidates), func(i, j int) {
candidates[i], candidates[j] = candidates[j], candidates[i]
})
} else {
// Default: Round Robin (based on LastUseTimeStamp ASC)
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].LastUseTimeStamp < candidates[j].LastUseTimeStamp
})
}

return candidates
}
53 changes: 49 additions & 4 deletions internal/op/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,30 @@ func ChannelList(ctx context.Context) ([]model.Channel, error) {
}

func ChannelCreate(channel *model.Channel, ctx context.Context) error {
if err := db.GetDB().WithContext(ctx).Create(channel).Error; err != nil {
keys := channel.Keys
channel.Keys = nil

tx := db.GetDB().WithContext(ctx).Begin()
if err := tx.Create(channel).Error; err != nil {
tx.Rollback()
return err
}

if len(keys) > 0 {
for i := range keys {
keys[i].ChannelID = channel.ID
}
if err := tx.CreateInBatches(keys, 50).Error; err != nil {
tx.Rollback()
return err
}
}

if err := tx.Commit().Error; err != nil {
return err
}

channel.Keys = keys
channelCache.Set(channel.ID, *channel)
for _, k := range channel.Keys {
if k.ID != 0 {
Expand Down Expand Up @@ -177,6 +198,22 @@ func ChannelUpdate(req *model.ChannelUpdateRequest, ctx context.Context) (*model
selectFields = append(selectFields, "match_regex")
updates.MatchRegex = req.MatchRegex
}
if req.EnableMultiKeyRetry != nil {
selectFields = append(selectFields, "enable_multi_key_retry")
updates.EnableMultiKeyRetry = *req.EnableMultiKeyRetry
}
if req.RetryCount != nil {
selectFields = append(selectFields, "retry_count")
updates.RetryCount = *req.RetryCount
}
if req.KeyLoadBalanceMode != nil {
selectFields = append(selectFields, "key_load_balance_mode")
updates.KeyLoadBalanceMode = *req.KeyLoadBalanceMode
}
if req.AutoBanKeyFailures != nil {
selectFields = append(selectFields, "auto_ban_key_failures")
updates.AutoBanKeyFailures = *req.AutoBanKeyFailures
}

// 只有当有字段需要更新时才执行 UPDATE
if len(selectFields) > 0 {
Expand All @@ -188,9 +225,17 @@ func ChannelUpdate(req *model.ChannelUpdateRequest, ctx context.Context) (*model

// 删除 keys
if len(req.KeysToDelete) > 0 {
if err := tx.Where("id IN ? AND channel_id = ?", req.KeysToDelete, req.ID).Delete(&model.ChannelKey{}).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to delete channel keys: %w", err)
batchSize := 50
for i := 0; i < len(req.KeysToDelete); i += batchSize {
end := i + batchSize
if end > len(req.KeysToDelete) {
end = len(req.KeysToDelete)
}
batch := req.KeysToDelete[i:end]
if err := tx.Where("id IN ? AND channel_id = ?", batch, req.ID).Delete(&model.ChannelKey{}).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to delete channel keys: %w", err)
}
}
}

Expand Down
6 changes: 4 additions & 2 deletions internal/relay/balancer/circuit.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ func RecordSuccess(channelID, keyID int, modelName string) {
entry.TripCount = 0
}

// RecordFailure 记录失败,可能触发熔断
func RecordFailure(channelID, keyID int, modelName string) {
// RecordFailure 记录失败,可能触发熔断,并返回当前的连续失败次数
func RecordFailure(channelID, keyID int, modelName string) int64 {
key := circuitKey(channelID, keyID, modelName)
entry := getOrCreateEntry(key)

Expand Down Expand Up @@ -174,4 +174,6 @@ func RecordFailure(channelID, keyID int, modelName string) {
// 理论上不应该在 Open 状态下接收到失败记录(请求应被拒绝),
// 但为安全起见仍更新失败时间
}

return entry.ConsecutiveFailures
}
98 changes: 61 additions & 37 deletions internal/relay/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,60 +95,75 @@ func Handler(inboundType inbound.InboundType, c *gin.Context) {
continue
}

usedKey := channel.GetChannelKey()
if usedKey.ChannelKey == "" {
iter.Skip(channel.ID, 0, channel.Name, "no available key")
continue
}

// 熔断检查
if iter.SkipCircuitBreak(channel.ID, usedKey.ID, channel.Name) {
continue
}

// 出站适配器
outAdapter := outbound.Get(channel.Type)
if outAdapter == nil {
iter.Skip(channel.ID, usedKey.ID, channel.Name, fmt.Sprintf("unsupported channel type: %d", channel.Type))
iter.Skip(channel.ID, 0, channel.Name, fmt.Sprintf("unsupported channel type: %d", channel.Type))
continue
}

// 类型兼容性检查
if internalRequest.IsEmbeddingRequest() && !outbound.IsEmbeddingChannelType(channel.Type) {
iter.Skip(channel.ID, usedKey.ID, channel.Name, "channel type not compatible with embedding request")
iter.Skip(channel.ID, 0, channel.Name, "channel type not compatible with embedding request")
continue
}
if internalRequest.IsChatRequest() && !outbound.IsChatChannelType(channel.Type) {
iter.Skip(channel.ID, usedKey.ID, channel.Name, "channel type not compatible with chat request")
iter.Skip(channel.ID, 0, channel.Name, "channel type not compatible with chat request")
continue
}

// 设置实际模型
internalRequest.Model = item.ModelName

log.Infof("request model %s, mode: %d, forwarding to channel: %s model: %s (attempt %d/%d, sticky=%t)",
requestModel, group.Mode, channel.Name, item.ModelName,
iter.Index()+1, iter.Len(), iter.IsSticky())

// 构造尝试级上下文 -- 只写变化的 4 个字段
ra := &relayAttempt{
relayRequest: req,
outAdapter: outAdapter,
channel: channel,
usedKey: usedKey,
firstTokenTimeOutSec: group.FirstTokenTimeOut,
candidateKeys := channel.GetCandidateKeys()
if len(candidateKeys) == 0 {
iter.Skip(channel.ID, 0, channel.Name, "no available key")
continue
}

result := ra.attempt()
if result.Success {
metrics.Save(c.Request.Context(), true, nil, iter.Attempts())
return
maxAttempts := 1
if channel.EnableMultiKeyRetry {
maxAttempts = channel.RetryCount
if maxAttempts < 1 {
maxAttempts = 1
}
if maxAttempts > len(candidateKeys) {
maxAttempts = len(candidateKeys)
}
}
if result.Written {
metrics.Save(c.Request.Context(), false, result.Err, iter.Attempts())
return

for i := 0; i < maxAttempts; i++ {
usedKey := candidateKeys[i]

// 熔断检查
if iter.SkipCircuitBreak(channel.ID, usedKey.ID, channel.Name) {
continue
}

// 设置实际模型
internalRequest.Model = item.ModelName

log.Infof("request model %s, mode: %d, forwarding to channel: %s model: %s key_id: %d (attempt %d/%d, key attempt %d/%d, sticky=%t)",
requestModel, group.Mode, channel.Name, item.ModelName, usedKey.ID,
iter.Index()+1, iter.Len(), i+1, maxAttempts, iter.IsSticky())

// 构造尝试级上下文 -- 只写变化的 4 个字段
ra := &relayAttempt{
relayRequest: req,
outAdapter: outAdapter,
channel: channel,
usedKey: usedKey,
firstTokenTimeOutSec: group.FirstTokenTimeOut,
}

result := ra.attempt()
if result.Success {
metrics.Save(c.Request.Context(), true, nil, iter.Attempts())
return
}
if result.Written {
metrics.Save(c.Request.Context(), false, result.Err, iter.Attempts())
return
}
lastErr = result.Err
}
lastErr = result.Err
}

// 所有通道都失败
Expand Down Expand Up @@ -200,7 +215,16 @@ func (ra *relayAttempt) attempt() attemptResult {
})

// 熔断器:记录失败
balancer.RecordFailure(ra.channel.ID, ra.usedKey.ID, ra.internalRequest.Model)
failures := balancer.RecordFailure(ra.channel.ID, ra.usedKey.ID, ra.internalRequest.Model)

// 自动禁用 Key 检查
if ra.channel.AutoBanKeyFailures > 0 && failures >= int64(ra.channel.AutoBanKeyFailures) {
ra.usedKey.Enabled = false
ra.usedKey.Remark += fmt.Sprintf(" [Auto banned: %d failures]", failures)
op.ChannelKeyUpdate(ra.usedKey)
log.Warnf("channel key %d disabled due to excessive failures (%d >= %d)",
ra.usedKey.ID, failures, ra.channel.AutoBanKeyFailures)
}

written := ra.c.Writer.Written()
if written {
Expand Down
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
Expand Down
Loading