Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
afa50d5
feat(channel): add OpenAIAdminKey field to ChannelOtherSettings
a-lapkov May 20, 2026
5bdcc59
test(channel): add scaffolding for channel_billing tests
a-lapkov May 20, 2026
f5a6d76
feat(channel): implement OpenAI balance via /v1/organization/costs
a-lapkov May 20, 2026
aced4d6
fix(channel): use 'page' param and add pagination iteration cap
a-lapkov May 20, 2026
b02cd1f
test(channel): cover paginated Costs API response
a-lapkov May 20, 2026
4278d40
test(channel): empty admin key returns explicit error
a-lapkov May 20, 2026
8140ab3
test(channel): surface upstream 403 errors verbatim
a-lapkov May 20, 2026
9e6e802
test(channel): wrap unmarshal failures with context
a-lapkov May 20, 2026
fa3f7a1
test(channel): assert Costs API query has correct start_time
a-lapkov May 20, 2026
27502b6
feat(channel): route OpenAI balance refresh to admin-key path
a-lapkov May 20, 2026
8eb8dad
feat(channel-form): wire openai_admin_key through form schema
a-lapkov May 20, 2026
1f78525
feat(channel): add OpenAI Admin Key field to mutate drawer
a-lapkov May 20, 2026
06b6212
i18n(channel): add OpenAI Admin Key field translations
a-lapkov May 20, 2026
a2de995
feat(channel): mask OpenAIAdminKey in Clean() and channel responses
a-lapkov May 20, 2026
d969c87
fix(channel): preserve openai_admin_key on update when payload is empty
a-lapkov May 20, 2026
7d4a65b
feat(channel-form): treat empty openai_admin_key as 'keep existing'
a-lapkov May 20, 2026
baa9ee5
i18n(channel): add 'Leave empty to keep existing admin key'
a-lapkov May 20, 2026
524a07b
feat(channel): add OpenAI prepaid fields to DTO
a-lapkov May 20, 2026
6ea0bae
feat(channel): compute remaining balance when prepaid is configured
a-lapkov May 20, 2026
3f887a4
test(channel): cover prepaid remaining, exhausted, and fallback paths
a-lapkov May 20, 2026
021953d
feat(channel-form): expose prepaid_amount + prepaid_since in UI
a-lapkov May 20, 2026
d2ef100
i18n(channel): add OpenAI prepaid field translations
a-lapkov May 20, 2026
0702b5e
fix(channel-form): align prepaid field defaults with schema type
a-lapkov May 20, 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
118 changes: 115 additions & 3 deletions controller/channel-billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/QuantumNous/new-api/common"
Expand Down Expand Up @@ -356,16 +358,126 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return availableBalanceUsd, nil
}

// Costs API response structures (https://platform.openai.com/docs/api-reference/usage/costs)

const openAICostsMaxPages = 50 // safety cap for pagination; daily buckets over a month never exceed this

type openAICostsResponse struct {
Object string `json:"object"`
Data []openAICostBucket `json:"data"`
HasMore bool `json:"has_more"`
NextPage string `json:"next_page"`
}

type openAICostBucket struct {
Object string `json:"object"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
Results []openAICostResult `json:"results"`
}

type openAICostResult struct {
Object string `json:"object"`
Amount openAICostAmount `json:"amount"`
}

type openAICostAmount struct {
Value float64 `json:"value"`
Currency string `json:"currency"`
}

// updateChannelOpenAIBalance fetches OpenAI usage via the Costs API and writes a balance
// value into channel.Balance. Requires an admin-scoped key (sk-admin-...) stored in
// channel.other_settings.openai_admin_key. The legacy /v1/dashboard/billing endpoints were
// deprecated by OpenAI; this function is their replacement.
//
// Two modes are supported:
// - Prepaid remaining: if both OpenAIPrepaidAmount > 0 and OpenAIPrepaidSince > 0 are set,
// costs are summed from OpenAIPrepaidSince to now, and the returned balance is
// OpenAIPrepaidAmount - total_costs (clamped to 0).
// - Month-to-date spend (fallback): otherwise, costs are summed from the 1st of the current
// UTC month to now and returned as-is.
func updateChannelOpenAIBalance(channel *model.Channel) (float64, error) {
settings := channel.GetOtherSettings()
adminKey := strings.TrimSpace(settings.OpenAIAdminKey)
if adminKey == "" {
return 0, errors.New("openai admin key is not set; configure channel.other_settings.openai_admin_key")
}

baseURL := channel.GetBaseURL()
if baseURL == "" {
baseURL = constant.ChannelBaseURLs[channel.Type]
}

now := time.Now().UTC()
prepaidMode := settings.OpenAIPrepaidAmount > 0 && settings.OpenAIPrepaidSince > 0
var start int64
if prepaidMode {
start = settings.OpenAIPrepaidSince
} else {
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Unix()
}
end := now.Unix()

var total float64
pageToken := ""
exhausted := true
for iter := 0; iter < openAICostsMaxPages; iter++ {
query := url.Values{}
query.Set("start_time", strconv.FormatInt(start, 10))
query.Set("end_time", strconv.FormatInt(end, 10))
query.Set("limit", "31")
if pageToken != "" {
query.Set("page", pageToken)
}
fullURL := fmt.Sprintf("%s/v1/organization/costs?%s", baseURL, query.Encode())

body, err := GetResponseBody("GET", fullURL, channel, GetAuthHeader(adminKey))
if err != nil {
return 0, fmt.Errorf("fetch openai usage: %w", err)
}

var resp openAICostsResponse
if err := common.Unmarshal(body, &resp); err != nil {
return 0, fmt.Errorf("parse openai usage: %w", err)
}

for _, bucket := range resp.Data {
for _, result := range bucket.Results {
total += result.Amount.Value
}
}

if !resp.HasMore || resp.NextPage == "" {
exhausted = false
break
}
pageToken = resp.NextPage
}
if exhausted {
return total, fmt.Errorf("openai costs pagination did not terminate after %d pages", openAICostsMaxPages)
}

balance := total
if prepaidMode {
balance = settings.OpenAIPrepaidAmount - total
if balance < 0 {
balance = 0
}
}

channel.UpdateBalance(balance)
return balance, nil
}

func updateChannelBalance(channel *model.Channel) (float64, error) {
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL
}
switch channel.Type {
case constant.ChannelTypeOpenAI:
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
return updateChannelOpenAIBalance(channel)
Comment on lines 479 to +480
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 | 🔴 Critical | ⚡ Quick win

OpenAI balance semantic change can incorrectly auto-disable healthy channels.

updateChannelOpenAIBalance now returns month-to-date spent amount, but downstream logic still treats channel.Balance as remaining and disables on balance <= 0. This can disable OpenAI channels at month start (spent = 0).

🔧 Minimal mitigation diff
 func updateAllChannelsBalance() error {
@@
-        } else {
-            // err is nil & balance <= 0 means quota is used up
-            if balance <= 0 {
+        } else {
+            // OpenAI balance is month-to-date spend (not remaining quota).
+            // Keep auto-disable logic only for channel types where balance semantics are "remaining".
+            if channel.Type != constant.ChannelTypeOpenAI && balance <= 0 {
                 service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
             }
         }
🤖 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 `@controller/channel-billing.go` around lines 458 - 459, The call site in
controller/channel-billing.go treats the result of updateChannelOpenAIBalance as
channel.Balance (remaining) but updateChannelOpenAIBalance now returns
month-to-date spent, causing healthy channels to be auto-disabled; fix by making
the behavior consistent: either change updateChannelOpenAIBalance to return
remaining balance (remaining = limit - spent) and set channel.Balance to that,
or keep it returning spent but update the caller to compute remaining before
using/disabling (use channel.Limit or billing limit to compute remaining and
only disable when remaining <= 0); ensure references to
updateChannelOpenAIBalance, channel.Balance, and the disable/threshold logic are
updated together so the semantic (remaining vs spent) is unambiguous.

case constant.ChannelTypeAzure:
return 0, errors.New("尚未实现")
case constant.ChannelTypeCustom:
Expand Down
20 changes: 20 additions & 0 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ func clearChannelInfo(channel *model.Channel) {
channel.ChannelInfo.MultiKeyDisabledReason = nil
channel.ChannelInfo.MultiKeyDisabledTime = nil
}
// Strip secrets embedded in OtherSettings JSON (e.g. OpenAIAdminKey) before serializing
// the channel back to API clients. The inference Key column is already cleared either via
// gorm Omit("key") at the query layer or by explicit channel.Key = "" in the caller.
if other := channel.GetOtherSettings(); other.OpenAIAdminKey != "" {
other.OpenAIAdminKey = ""
channel.SetOtherSettings(other)
}
}

func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
Expand Down Expand Up @@ -889,6 +896,19 @@ func UpdateChannel(c *gin.Context) {
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
channel.ChannelInfo = originChannel.ChannelInfo

// Preserve secrets embedded in OtherSettings when the client sends an empty value.
// The form masks OpenAIAdminKey on edit (the GET endpoint never returns it), so an empty
// admin_key in the PUT payload means "keep existing", not "clear". Without this merge,
// gorm's struct-based Updates(channel) would overwrite settings JSON wholesale.
incomingOther := channel.GetOtherSettings()
if incomingOther.OpenAIAdminKey == "" {
originOther := originChannel.GetOtherSettings()
if originOther.OpenAIAdminKey != "" {
incomingOther.OpenAIAdminKey = originOther.OpenAIAdminKey
channel.SetOtherSettings(incomingOther)
}
}
Comment on lines +903 to +910
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

Prevent stale admin-key retention when channel type is not OpenAI.

At Line 904, the preservation merge runs regardless of channel type. If a channel is edited from OpenAI to another type, this can keep openai_admin_key in settings unintentionally.

Suggested fix
-	incomingOther := channel.GetOtherSettings()
-	if incomingOther.OpenAIAdminKey == "" {
+	incomingOther := channel.GetOtherSettings()
+	if channel.Type == constant.ChannelTypeOpenAI && incomingOther.OpenAIAdminKey == "" {
 		originOther := originChannel.GetOtherSettings()
 		if originOther.OpenAIAdminKey != "" {
 			incomingOther.OpenAIAdminKey = originOther.OpenAIAdminKey
 			channel.SetOtherSettings(incomingOther)
 		}
 	}
🤖 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 `@controller/channel.go` around lines 903 - 910, The merge logic
unconditionally preserves OpenAIAdminKey; restrict it so the key is only carried
over when the channel is (still) an OpenAI channel: check the channel type
before copying (e.g. ensure channel.GetType() or channel.Type == "openai") and
only set incomingOther.OpenAIAdminKey from originOther.OpenAIAdminKey in that
case; additionally, if the incoming channel type is not OpenAI, ensure
incomingOther.OpenAIAdminKey is cleared (set to empty) and call
channel.SetOtherSettings(incomingOther) to avoid retaining a stale key.


// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
Expand Down
Loading