Skip to content
Closed
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
2 changes: 2 additions & 0 deletions controller/channel_upstream_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ func checkAndPersistChannelUpstreamModelUpdates(
if err = channel.UpdateAbilities(nil); err != nil {
return true, autoAdded, err
}
_ = model.EnsureChannelVendorAndModels(channel, nil)
}
return modelsChanged, autoAdded, nil
}
Expand Down Expand Up @@ -962,6 +963,7 @@ func applyChannelUpstreamModelUpdates(
if err := channel.UpdateAbilities(nil); err != nil {
return addModels, removeModels, remainingModels, remainingRemoveModels, true, err
}
_ = model.EnsureChannelVendorAndModels(channel, nil)
}
return addModels, removeModels, remainingModels, remainingRemoveModels, modelsChanged, nil
}
Expand Down
7 changes: 7 additions & 0 deletions model/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ func BatchInsertChannels(channels []Channel) error {
tx.Rollback()
return err
}
_ = EnsureChannelVendorAndModels(&channel_, tx)
}
}
return tx.Commit().Error
Expand Down Expand Up @@ -526,6 +527,9 @@ func (channel *Channel) Insert() error {
return err
}
err = channel.AddAbilities(nil)
if err == nil {
_ = EnsureChannelVendorAndModels(channel, nil)
}
return err
}

Expand Down Expand Up @@ -575,6 +579,9 @@ func (channel *Channel) Update() error {
}
DB.Model(channel).First(channel, "id = ?", channel.Id)
err = channel.UpdateAbilities(nil)
if err == nil {
_ = EnsureChannelVendorAndModels(channel, nil)
}
return err
}

Expand Down
48 changes: 48 additions & 0 deletions model/vendor_channel_auto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package model

import (
"fmt"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEnsureVendorAndAutoBindModelsForNewChannel(t *testing.T) {
require.NoError(t, DB.AutoMigrate(&Channel{}, &Ability{}, &Vendor{}, &Model{}))
Comment on lines +13 to +14

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Create an isolated database fixture.

This test uses the global DB without initializing or restoring it. It can depend on suite order and leave migrated tables and rows in a shared test database.

Initialize an isolated test database, assign and restore DB, and clean up through t.Cleanup. As per coding guidelines, backend tests must initialize database state explicitly in test fixtures.

🤖 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 `@model/vendor_channel_auto_test.go` around lines 13 - 14, Update
TestEnsureVendorAndAutoBindModelsForNewChannel to create an isolated database
fixture before calling DB.AutoMigrate, assign that fixture to the global DB, and
restore the previous DB value via t.Cleanup. Ensure the fixture’s database
resources and migrated state are also cleaned up through t.Cleanup so the test
is independent of suite order and shared data.

Source: Coding guidelines


uniqueChannelName := fmt.Sprintf("AutoVendorChan_%d", common.GetTimestamp())
testModel1 := fmt.Sprintf("auto-test-model-1-%d", common.GetTimestamp())
testModel2 := fmt.Sprintf("auto-test-model-2-%d", common.GetTimestamp())

channel := &Channel{
Type: constant.ChannelTypeAdvancedCustom,
Name: uniqueChannelName,
Key: "sk-test-key",
Models: fmt.Sprintf("%s,%s", testModel1, testModel2),
Status: common.ChannelStatusEnabled,
}

err := channel.Insert()
require.NoError(t, err)

// 1. Verify Vendor was auto-created
var vendor Vendor
err = DB.Where("name = ? AND deleted_at IS NULL", uniqueChannelName).First(&vendor).Error
require.NoError(t, err)
assert.Equal(t, uniqueChannelName, vendor.Name)
assert.Equal(t, 1, vendor.Status)

// 2. Verify model metadata was auto-created and bound to vendor.Id
var meta1 Model
err = DB.Where("model_name = ? AND deleted_at IS NULL", testModel1).First(&meta1).Error
require.NoError(t, err)
assert.Equal(t, vendor.Id, meta1.VendorID)

var meta2 Model
err = DB.Where("model_name = ? AND deleted_at IS NULL", testModel2).First(&meta2).Error
require.NoError(t, err)
assert.Equal(t, vendor.Id, meta2.VendorID)
}
125 changes: 125 additions & 0 deletions model/vendor_meta.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package model

import (
"errors"
"fmt"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"

"gorm.io/gorm"
)
Expand Down Expand Up @@ -86,3 +91,123 @@ func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, err
}
return vendors, total, nil
}

func resolveVendorNameAndIcon(channel *Channel) (string, string) {
if channel == nil {
return "Custom", "Globe"
}

name := strings.TrimSpace(channel.Name)
switch channel.Type {
case constant.ChannelTypeAdvancedCustom, constant.ChannelTypeNewAPI, constant.ChannelTypeSub2API, constant.ChannelTypeCustom:
if name != "" {
return name, "Globe"
}
return constant.GetChannelTypeName(channel.Type), "Globe"
default:
typeName := constant.GetChannelTypeName(channel.Type)
if typeName != "" && typeName != "Unknown" {
return typeName, "Globe"
}
if name != "" {
return name, "Globe"
}
return "Custom", "Globe"
}
}

// EnsureVendorForChannel 检查并自动创建渠道对应的供应商记录,返回 Vendor ID
func EnsureVendorForChannel(channel *Channel, tx *gorm.DB) (int, error) {
if channel == nil {
return 0, nil
}

name, icon := resolveVendorNameAndIcon(channel)
if name == "" {
return 0, nil
}

useDB := DB
if tx != nil {
useDB = tx
}

var vendor Vendor
err := useDB.Where("name = ? AND deleted_at IS NULL", name).First(&vendor).Error
if err == nil {
return vendor.Id, nil
}

if errors.Is(err, gorm.ErrRecordNotFound) {
newVendor := Vendor{
Name: name,
Description: fmt.Sprintf("%s 渠道自动创建供应商", name),
Icon: icon,
Status: 1,
CreatedTime: common.GetTimestamp(),
UpdatedTime: common.GetTimestamp(),
}
if err := useDB.Create(&newVendor).Error; err != nil {
return 0, err
}
return newVendor.Id, nil
Comment on lines +136 to +153

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent duplicate active vendor rows.

EnsureVendorForChannel uses a read-then-create sequence. The (name, deleted_at) unique index does not enforce one active row because MySQL, PostgreSQL, and SQLite allow multiple NULL values in a unique key. Concurrent calls can both find no row and create vendors with deleted_at IS NULL.

Use a portable active-name uniqueness strategy. Then handle a conflicting create by loading the existing vendor. As per coding guidelines, database code must support SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6.

🤖 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 `@model/vendor_meta.go` around lines 136 - 153, Update EnsureVendorForChannel
to enforce active vendor-name uniqueness with a portable non-null key column
populated only for active vendors and a unique index on that key, compatible
with SQLite, MySQL 5.7.8+, and PostgreSQL 9.6+. Populate or clear the key
consistently when vendors are created or soft-deleted, and when Create conflicts
on the unique constraint, reload and return the existing active Vendor instead
of returning the error.

Source: Coding guidelines

}

return 0, err
}

// AutoBindChannelModelsToVendor 为渠道拥有的模型自动补充元数据并绑定 Vendor ID
func AutoBindChannelModelsToVendor(channel *Channel, vendorID int, tx *gorm.DB) error {
if channel == nil || channel.Models == "" || vendorID <= 0 {
return nil
}

useDB := DB
if tx != nil {
useDB = tx
}

models := strings.Split(channel.Models, ",")
now := common.GetTimestamp()

for _, modelName := range models {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
continue
}

var existing Model
err := useDB.Where("model_name = ? AND deleted_at IS NULL", modelName).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
newModel := Model{
ModelName: modelName,
VendorID: vendorID,
Status: 1,
SyncOfficial: 0,
Endpoints: `["openai"]`,
CreatedTime: now,
UpdatedTime: now,
}
if err := useDB.Create(&newModel).Error; err != nil {
common.SysError(fmt.Sprintf("auto bind model failed: %s, err: %v", modelName, err))
}
} else if err == nil && existing.VendorID == 0 {
useDB.Model(&existing).Update("vendor_id", vendorID)
}
Comment on lines +180 to +196

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not report successful synchronization after a metadata write fails.

AutoBindChannelModelsToVendor drops database errors, and every caller discards the returned synchronization error. A channel operation can therefore succeed while vendor or model metadata is missing.

  • model/vendor_meta.go#L180-L196: return unexpected lookup errors and model create or update errors instead of only logging them.
  • model/channel.go#L450-L450: roll back and return the synchronization error before committing the batch transaction.
  • model/channel.go#L530-L532: return the synchronization error, or persist a durable retry state if channel insertion cannot be atomic.
  • model/channel.go#L582-L584: return the synchronization error, or persist a durable retry state if channel updates cannot be atomic.
  • controller/channel_upstream_update.go#L519-L519: propagate or durably retry the synchronization failure after automatic model application.
  • controller/channel_upstream_update.go#L966-L966: propagate or durably retry the synchronization failure after manual model application.
📍 Affects 3 files
  • model/vendor_meta.go#L180-L196 (this comment)
  • model/channel.go#L450-L450
  • model/channel.go#L530-L532
  • model/channel.go#L582-L584
  • controller/channel_upstream_update.go#L519-L519
  • controller/channel_upstream_update.go#L966-L966
🤖 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 `@model/vendor_meta.go` around lines 180 - 196, Update
model/vendor_meta.go:180-196 in AutoBindChannelModelsToVendor to return
unexpected lookup errors and model create or update errors instead of only
logging them. Update model/channel.go:450-450 to roll back and return the
synchronization error before committing the batch transaction; at
model/channel.go:530-532 and 582-584, return the error or persist a durable
retry state when atomicity is unavailable. At
controller/channel_upstream_update.go:519-519 and 966-966, propagate or durably
retry synchronization failures after automatic and manual model application.

}
return nil
}

// EnsureChannelVendorAndModels 自动确保渠道对应的供应商和模型元数据已关联
func EnsureChannelVendorAndModels(channel *Channel, tx *gorm.DB) error {
if channel == nil {
return nil
}
vendorID, err := EnsureVendorForChannel(channel, tx)
if err != nil {
common.SysError(fmt.Sprintf("EnsureVendorForChannel failed for channel %d (%s): %v", channel.Id, channel.Name, err))
return err
}
return AutoBindChannelModelsToVendor(channel, vendorID, tx)
}

3 changes: 2 additions & 1 deletion relay/channel/gemini/constant.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ var ModelList = []string{
// stable version
"gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash",
"gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", "gemini-2.0-flash-lite",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite", "gemini-3.1-flash-lite", "gemini-3.5-flash-lite",
"gemini-3.6", "gemini-3.6-flash",

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify whether an explicit Gemini upstream-name translation exists.
ast-grep outline relay/channel/gemini --items all
rg -n --type=go -C 5 'gemini-3\.6|UpstreamModelName|OriginModelName|ModelList' .

Repository: QuantumNous/new-api

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Gemini files:\n'
git ls-files 'relay/channel/gemini/*'

printf '\nGemini constants/adaptor outline/selected content:\n'
sed -n '1,80p' relay/channel/gemini/constant.go
printf '\n'
sed -n '1,260p' relay/channel/gemini/adaptor.go

printf '\nRelevant ratio settings:\n'
sed -n '1,45p' setting/ratio_setting/cache_ratio.go
sed -n '180,210p' setting/ratio_setting/model_ratio.go
sed -n '330,348p' setting/ratio_setting/model_ratio.go
sed -n '580,602p' setting/ratio_setting/model_ratio.go
sed -n '24,38p' setting/ratio_setting/model_ratio_test.go

printf '\nFocused upstream-name translation/search for Gemini 3 identifiers:\n'
rg -n --type=go -C 3 'gemini-3\.6|gemini-3\.6-flash|UpstreamModelName|OriginModelName|ConvertOpenAIRequest|RequestURL.*gemini|Info\.UpstreamModelName|body\.Model' relay setting/dto relay/channel/gemini | sed -n '1,240p'

Repository: QuantumNous/new-api

Length of output: 32367


🌐 Web query:

Google Gemini API pricing supported model versions gemini-3.6 gemini-3.6-flash

💡 Result:

As of August 10, 2026, Gemini 3.6 Flash is a generally available (GA) model in the Gemini API and the Gemini Enterprise Agent Platform [1][2][3]. Gemini API Pricing for Gemini 3.6 Flash (Paid Tier) The following pricing applies per 1 million tokens for the gemini-3.6-flash model [4]: Input Price: $1.50 per 1 million tokens [4]. Output Price: $7.50 per 1 million tokens (includes thinking tokens) [4]. Context Caching Storage Price: $1.00 per 1 million tokens per hour [4]. Grounding with Google Search/Maps: The first 5,000 requests per month are free (shared across the Gemini 3 model family); subsequent requests are $14.00 per 1,000 search queries [4]. Key Model Details Model ID: gemini-3.6-flash [1][2] Release Date: July 21, 2026 [2][3] Context Window: 1,048,576 tokens [1][2] Max Output Tokens: 65,536 tokens [1][2] Capabilities: Includes support for function calling, structured outputs, search grounding, context caching, and code execution [1][2]. Note: There is no Free Tier for gemini-3.6-flash in the Gemini API [4]. While a free tier exists for some models within the Gemini API, pricing and availability depend on the specific model selected [5]. You can verify the most current rates and specific model availability directly through the official Gemini API pricing documentation [4].

Citations:


Remove the unsupported upstream identifier gemini-3.6.

gemini-3.6-flash is the supported Gemini model ID; the bare gemini-3.6 entry can be sent upstream because the Gemini adaptor builds request URLs from info.UpstreamModelName without translating it. Remove all configuration and test paths for the bare identifier, and keep gemini-3.6-flash in the Gemini model list.

📍 Affects 4 files
  • relay/channel/gemini/constant.go#L8-L8 (this comment)
  • setting/ratio_setting/cache_ratio.go#L13-L13
  • setting/ratio_setting/model_ratio.go#L195-L195
  • setting/ratio_setting/model_ratio.go#L339-L339
  • setting/ratio_setting/model_ratio.go#L592-L593
  • setting/ratio_setting/model_ratio_test.go#L30-L34
🤖 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 `@relay/channel/gemini/constant.go` at line 8, Remove the unsupported bare
gemini-3.6 identifier while retaining gemini-3.6-flash: update
relay/channel/gemini/constant.go:8-8,
setting/ratio_setting/cache_ratio.go:13-13,
setting/ratio_setting/model_ratio.go:195-195, 339-339, and 592-593 to eliminate
its configuration entries, and update
setting/ratio_setting/model_ratio_test.go:30-34 to remove its test coverage.
Ensure no configuration or test path continues to reference gemini-3.6.

"gemini-3-pro-image", "gemini-3.1-flash-image",
// latest version
"gemini-flash-latest", "gemini-flash-lite-latest", "gemini-pro-latest",
Expand Down
4 changes: 4 additions & 0 deletions setting/ratio_setting/cache_ratio.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ var defaultCacheRatio = map[string]float64{
"gemini-3-flash-preview": 0.1,
"gemini-3-pro-preview": 0.1,
"gemini-3.1-pro-preview": 0.1,
"gemini-3.1-flash-lite": 0.1,
"gemini-3.5-flash-lite": 0.1,
"gemini-3.6": 0.1,
"gemini-3.6-flash": 0.1,
"gpt-4": 0.5,
"o1": 0.5,
"o1-2024-12-17": 0.5,
Expand Down
22 changes: 18 additions & 4 deletions setting/ratio_setting/model_ratio.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"gemini-3.1-flash-lite": 0.125,
"gemini-3.5-flash-lite": 0.15,
"gemini-3.6": 0.75,
"gemini-3.6-flash": 0.75,
"gemini-robotics-er-1.5-preview": 0.15,
"gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
Expand Down Expand Up @@ -326,10 +330,14 @@ var modelRatioMap = types.NewRWMap[string, float64]()
var completionRatioMap = types.NewRWMap[string, float64]()

var defaultCompletionRatio = map[string]float64{
"gpt-4-gizmo-*": 2,
"gpt-4o-gizmo-*": 3,
"gpt-4-all": 2,
"gpt-image-1": 8,
"gpt-4-gizmo-*": 2,
"gpt-4o-gizmo-*": 3,
"gpt-4-all": 2,
"gpt-image-1": 8,
"gemini-3.1-flash-lite": 6,
"gemini-3.5-flash-lite": 2.5 / 0.3,
"gemini-3.6": 5,
"gemini-3.6-flash": 5,
}

// InitRatioSettings initializes all model related settings maps
Expand Down Expand Up @@ -577,6 +585,12 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-3.1-flash-lite") {
return 6, false
} else if strings.HasPrefix(name, "gemini-3.5-flash-lite") {
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-3.6") {
return 5, false
} else if strings.HasPrefix(name, "gemini-3-pro") {
if strings.HasPrefix(name, "gemini-3-pro-image") {
return 60, false
Expand Down
52 changes: 52 additions & 0 deletions setting/ratio_setting/model_ratio_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package ratio_setting_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/QuantumNous/new-api/setting/ratio_setting"
)

func TestGemini3ModelRatios(t *testing.T) {
ratio_setting.InitRatioSettings()

tests := []struct {
model string
expectedModelRatio float64
expectedCompRatio float64
Comment on lines +15 to +18

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test the cache-ratio billing invariant.

These cases do not call GetCacheRatio. A missing or incorrect cache ratio can change cache-token billing without failing this test. Add the expected cache ratio and assert it for every supported model.

Proposed test update
 	tests := []struct {
 		model              string
 		expectedModelRatio float64
 		expectedCompRatio  float64
+		expectedCacheRatio float64
 	}{
 		{
 			model:              "gemini-3.1-flash-lite",
 			expectedModelRatio: 0.125,
 			expectedCompRatio:  6.0,
+			expectedCacheRatio: 0.1,
 		},
 			compRatio := ratio_setting.GetCompletionRatio(tt.model)
 			assert.InDelta(t, tt.expectedCompRatio, compRatio, 0.0001, "completion ratio mismatch for %s", tt.model)
+
+			cacheRatio, ok := ratio_setting.GetCacheRatio(tt.model)
+			require.True(t, ok, "cache ratio for %s should exist", tt.model)
+			assert.InDelta(t, tt.expectedCacheRatio, cacheRatio, 0.0001, "cache ratio mismatch for %s", tt.model)

As per coding guidelines, “Backend tests must protect … billing/accounting invariants.”

Also applies to: 42-49

🤖 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 `@setting/ratio_setting/model_ratio_test.go` around lines 15 - 18, Update the
model ratio test table and assertions to cover the cache-ratio billing
invariant: add an expected cache ratio for each supported model, call
GetCacheRatio for every case, and assert the returned value alongside the
existing model and completion ratio checks.

Source: Coding guidelines

}{
{
model: "gemini-3.1-flash-lite",
expectedModelRatio: 0.125,
expectedCompRatio: 6.0,
},
{
model: "gemini-3.5-flash-lite",
expectedModelRatio: 0.15,
expectedCompRatio: 2.5 / 0.3,
},
{
model: "gemini-3.6",
expectedModelRatio: 0.75,
expectedCompRatio: 5.0,
},
{
model: "gemini-3.6-flash",
expectedModelRatio: 0.75,
expectedCompRatio: 5.0,
},
}

for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
ratio, ok, _ := ratio_setting.GetModelRatio(tt.model)
require.True(t, ok, "model ratio for %s should exist", tt.model)
assert.InDelta(t, tt.expectedModelRatio, ratio, 0.0001, "model ratio mismatch for %s", tt.model)

compRatio := ratio_setting.GetCompletionRatio(tt.model)
assert.InDelta(t, tt.expectedCompRatio, compRatio, 0.0001, "completion ratio mismatch for %s", tt.model)
})
}
}