Skip to content
Draft
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: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog

All notable changes to this project will be documented in this file.

## Unreleased

### Added
- Added regression coverage for channel cache refresh after channel create, update, and delete operations.

### Fixed
- Refreshed the in-memory channel cache immediately after channel create, update, delete, and status changes so newly configured channels are available without waiting for the periodic sync loop.
- Invalidated cached per-group model lists after channel changes so `/v1/models` and request routing stay consistent after channel edits.

### Docs
- Documented the channel cache refresh behavior in the Chinese and English FAQ sections.
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ If the channel ID is not provided, load balancing will be used to distribute the
3. It says "No available channels" when trying to use a channel. What should I do?
+ Please check the user and channel group settings.
+ Also check the channel model settings.
+ Channel changes now refresh the channel cache and per-group model cache immediately. If the error still appears after adding or editing a channel, double-check that the group and model assignments match the token you are using.
4. Channel testing reports an error: "invalid character '<' looking for beginning of value"
+ This error occurs when the returned value is not valid JSON but an HTML page.
+ Most likely, the IP of your deployment site or the node of the proxy has been blocked by CloudFlare.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ https://openai.justsong.cn
3. 提示无可用渠道?
+ 请检查的用户分组和渠道分组设置。
+ 以及渠道的模型设置。
+ 如果你刚新增、编辑或删除渠道,当前版本会立即刷新渠道缓存和分组模型缓存;如果仍然报错,优先检查分组和模型配置是否匹配。
4. 渠道测试报错:`invalid character '<' looking for beginning of value`
+ 这是因为返回值不是合法的 JSON,而是一个 HTML 页面。
+ 大概率是你的部署站的 IP 或代理的节点被 CloudFlare 封禁了。
Expand Down
36 changes: 36 additions & 0 deletions model/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,42 @@ func CacheGetGroupModels(ctx context.Context, group string) ([]string, error) {
var group2model2channels map[string]map[string][]*Channel
var channelSyncLock sync.RWMutex

func RefreshChannelCaches() {
if config.MemoryCacheEnabled {
InitChannelCache()
}
InvalidateGroupModelsCache()
}

// Channel writes are infrequent, so clearing the per-group model cache
// wholesale is the simplest way to avoid stale entries after adds, edits,
// deletions, or group/model moves.
func InvalidateGroupModelsCache() {
if !common.RedisEnabled || common.RDB == nil {
return
}
ctx := context.Background()
var cursor uint64
for {
keys, nextCursor, err := common.RDB.Scan(ctx, cursor, "group_models:*", 100).Result()
if err != nil {
logger.SysError("Redis scan group models cache error: " + err.Error())
return
}
if len(keys) > 0 {
err = common.RDB.Del(ctx, keys...).Err()
if err != nil {
logger.SysError("Redis delete group models cache error: " + err.Error())
return
}
}
if nextCursor == 0 {
return
}
cursor = nextCursor
}
}

func InitChannelCache() {
newChannelId2channel := make(map[int]*Channel)
var channels []*Channel
Expand Down
20 changes: 20 additions & 0 deletions model/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func BatchInsertChannels(channels []Channel) error {
return err
}
}
RefreshChannelCaches()
return nil
}

Expand Down Expand Up @@ -131,6 +132,10 @@ func (channel *Channel) Insert() error {
return err
}
err = channel.AddAbilities()
if err != nil {
return err
}
RefreshChannelCaches()
return err
}

Expand All @@ -142,6 +147,10 @@ func (channel *Channel) Update() error {
}
DB.Model(channel).First(channel, "id = ?", channel.Id)
err = channel.UpdateAbilities()
if err != nil {
return err
}
RefreshChannelCaches()
return err
}

Expand Down Expand Up @@ -172,6 +181,10 @@ func (channel *Channel) Delete() error {
return err
}
err = channel.DeleteAbilities()
if err != nil {
return err
}
RefreshChannelCaches()
return err
}

Expand All @@ -196,6 +209,7 @@ func UpdateChannelStatusById(id int, status int) {
if err != nil {
logger.SysError("failed to update channel status: " + err.Error())
}
RefreshChannelCaches()
}

func UpdateChannelUsedQuota(id int, quota int64) {
Expand All @@ -215,10 +229,16 @@ func updateChannelUsedQuota(id int, quota int64) {

func DeleteChannelByStatus(status int64) (int64, error) {
result := DB.Where("status = ?", status).Delete(&Channel{})
if result.Error == nil && result.RowsAffected > 0 {
RefreshChannelCaches()
}
return result.RowsAffected, result.Error
}

func DeleteDisabledChannel() (int64, error) {
result := DB.Where("status = ? or status = ?", ChannelStatusAutoDisabled, ChannelStatusManuallyDisabled).Delete(&Channel{})
if result.Error == nil && result.RowsAffected > 0 {
RefreshChannelCaches()
}
return result.RowsAffected, result.Error
}
130 changes: 130 additions & 0 deletions model/channel_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package model

import (
"os"
"path/filepath"
"testing"

"github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/config"
)

func setupChannelCacheTestDB(t *testing.T) {
t.Helper()

oldSQLitePath := common.SQLitePath
oldUsingSQLite := common.UsingSQLite
oldUsingMySQL := common.UsingMySQL
oldUsingPostgreSQL := common.UsingPostgreSQL
oldMemoryCacheEnabled := config.MemoryCacheEnabled
oldDB := DB
oldLogDB := LOG_DB
oldGroup2Model2Channels := group2model2channels

common.SQLitePath = filepath.Join(t.TempDir(), "one-api-test.db")
common.UsingSQLite = false
common.UsingMySQL = false
common.UsingPostgreSQL = false
config.MemoryCacheEnabled = true
group2model2channels = nil

t.Setenv("SQL_DSN", "")
InitDB()
LOG_DB = DB

t.Cleanup(func() {
if DB != nil {
_ = closeDB(DB)
}
DB = oldDB
LOG_DB = oldLogDB
common.SQLitePath = oldSQLitePath
common.UsingSQLite = oldUsingSQLite
common.UsingMySQL = oldUsingMySQL
common.UsingPostgreSQL = oldUsingPostgreSQL
config.MemoryCacheEnabled = oldMemoryCacheEnabled
group2model2channels = oldGroup2Model2Channels
_ = os.Remove(common.SQLitePath)
})
}

func TestBatchInsertChannelsRefreshesMemoryChannelCache(t *testing.T) {
setupChannelCacheTestDB(t)

InitChannelCache()

err := BatchInsertChannels([]Channel{{
Name: "qwen",
Type: 1,
Key: "test-key",
Status: ChannelStatusEnabled,
Models: "qwen3.6-plus",
Group: "default",
}})
if err != nil {
t.Fatalf("BatchInsertChannels returned error: %v", err)
}

_, err = CacheGetRandomSatisfiedChannel("default", "qwen3.6-plus", false)
if err != nil {
t.Fatalf("expected inserted channel to be immediately available from memory cache, got error: %v", err)
}
}

func TestChannelUpdateRefreshesMemoryChannelCache(t *testing.T) {
setupChannelCacheTestDB(t)

channel := Channel{
Name: "qwen",
Type: 1,
Key: "test-key",
Status: ChannelStatusEnabled,
Models: "qwen3.6-plus",
Group: "default",
}
if err := channel.Insert(); err != nil {
t.Fatalf("Insert returned error: %v", err)
}
InitChannelCache()

channel.Models = "qwen3-coder-next"
if err := channel.Update(); err != nil {
t.Fatalf("Update returned error: %v", err)
}

_, err := CacheGetRandomSatisfiedChannel("default", "qwen3-coder-next", false)
if err != nil {
t.Fatalf("expected updated model to be immediately available from memory cache, got error: %v", err)
}

_, err = CacheGetRandomSatisfiedChannel("default", "qwen3.6-plus", false)
if err == nil {
t.Fatalf("expected removed model to disappear from memory cache after update")
}
}

func TestChannelDeleteRefreshesMemoryChannelCache(t *testing.T) {
setupChannelCacheTestDB(t)

channel := Channel{
Name: "qwen",
Type: 1,
Key: "test-key",
Status: ChannelStatusEnabled,
Models: "qwen3.6-plus",
Group: "default",
}
if err := channel.Insert(); err != nil {
t.Fatalf("Insert returned error: %v", err)
}
InitChannelCache()

if err := channel.Delete(); err != nil {
t.Fatalf("Delete returned error: %v", err)
}

_, err := CacheGetRandomSatisfiedChannel("default", "qwen3.6-plus", false)
if err == nil {
t.Fatalf("expected deleted channel to be removed from memory cache")
}
}