diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 751ee3600ac..a978241808c 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -356,6 +358,118 @@ 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() == "" { @@ -363,9 +477,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { } switch channel.Type { case constant.ChannelTypeOpenAI: - if channel.GetBaseURL() != "" { - baseURL = channel.GetBaseURL() - } + return updateChannelOpenAIBalance(channel) case constant.ChannelTypeAzure: return 0, errors.New("尚未实现") case constant.ChannelTypeCustom: diff --git a/controller/channel.go b/controller/channel.go index 217351703fc..db49f584cca 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -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 { @@ -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) + } + } + // 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) diff --git a/controller/channel_billing_test.go b/controller/channel_billing_test.go new file mode 100644 index 00000000000..ee5e47df872 --- /dev/null +++ b/controller/channel_billing_test.go @@ -0,0 +1,338 @@ +package controller + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + + "github.com/stretchr/testify/require" +) + +// buildOpenAIChannelWithAdminKey creates an in-memory OpenAI channel pointing at the given baseURL. +// adminKey is stored inside ChannelOtherSettings.OpenAIAdminKey. If empty, the field is omitted. +func buildOpenAIChannelWithAdminKey(t *testing.T, baseURL, adminKey string) *model.Channel { + t.Helper() + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + settings := dto.ChannelOtherSettings{} + if adminKey != "" { + settings.OpenAIAdminKey = adminKey + } + encoded, err := common.Marshal(settings) + require.NoError(t, err) + bURL := baseURL + ch := &model.Channel{ + Type: constant.ChannelTypeOpenAI, + Key: "sk-fake-inference-key", + Status: 1, + Name: "test-openai", + BaseURL: &bURL, + OtherSettings: string(encoded), + } + require.NoError(t, model.DB.Create(ch).Error) + return ch +} + +// firstDayOfCurrentMonthUTC returns the Unix timestamp of the 1st day of the current month at 00:00 UTC. +func firstDayOfCurrentMonthUTC() int64 { + now := time.Now().UTC() + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Unix() +} + +func TestUpdateChannelOpenAIBalance_SingleBucket(t *testing.T) { + _ = openTokenControllerTestDB(t) + + var capturedAuth string + var capturedQuery url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{ + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1747008000, + "end_time": 1747094400, + "results": [ + {"object": "organization.costs.result", "amount": {"value": 10.5, "currency": "usd"}} + ] + } + ], + "has_more": false, + "next_page": "" + }`) + })) + defer ts.Close() + + ch := buildOpenAIChannelWithAdminKey(t, ts.URL, "sk-admin-test") + + balance, err := updateChannelOpenAIBalance(ch) + require.NoError(t, err) + require.InDelta(t, 10.5, balance, 0.001) + require.Equal(t, "Bearer sk-admin-test", capturedAuth) + require.Equal(t, strconv.FormatInt(firstDayOfCurrentMonthUTC(), 10), capturedQuery.Get("start_time")) + + var fresh model.Channel + require.NoError(t, model.DB.First(&fresh, ch.Id).Error) + require.InDelta(t, 10.5, fresh.Balance, 0.001) +} + +func TestUpdateChannelOpenAIBalance_Pagination(t *testing.T) { + _ = openTokenControllerTestDB(t) + + callCount := 0 + var capturedTokenOnSecondCall string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + _, _ = fmt.Fprint(w, `{ + "object": "page", + "data": [{"object": "bucket","start_time": 1747008000,"end_time": 1747094400, + "results": [{"object": "organization.costs.result","amount": {"value": 5.0,"currency": "usd"}}]}], + "has_more": true, + "next_page": "page-token-2" + }`) + return + } + capturedTokenOnSecondCall = r.URL.Query().Get("page") + _, _ = fmt.Fprint(w, `{ + "object": "page", + "data": [{"object": "bucket","start_time": 1747094400,"end_time": 1747180800, + "results": [{"object": "organization.costs.result","amount": {"value": 7.25,"currency": "usd"}}]}], + "has_more": false, + "next_page": "" + }`) + })) + defer ts.Close() + + ch := buildOpenAIChannelWithAdminKey(t, ts.URL, "sk-admin-test") + + balance, err := updateChannelOpenAIBalance(ch) + require.NoError(t, err) + require.Equal(t, 2, callCount) + require.Equal(t, "page-token-2", capturedTokenOnSecondCall) + require.InDelta(t, 12.25, balance, 0.001) +} + +func TestUpdateChannelOpenAIBalance_NoAdminKey(t *testing.T) { + _ = openTokenControllerTestDB(t) + ch := buildOpenAIChannelWithAdminKey(t, "http://unused", "") + + balance, err := updateChannelOpenAIBalance(ch) + require.Error(t, err) + require.Contains(t, err.Error(), "openai admin key is not set") + require.Equal(t, float64(0), balance) + + var fresh model.Channel + require.NoError(t, model.DB.First(&fresh, ch.Id).Error) + require.Equal(t, float64(0), fresh.Balance) +} + +func TestUpdateChannelOpenAIBalance_Upstream403(t *testing.T) { + _ = openTokenControllerTestDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"error":{"message":"missing scope","type":"insufficient_permissions"}}`) + })) + defer ts.Close() + + ch := buildOpenAIChannelWithAdminKey(t, ts.URL, "sk-admin-test") + + balance, err := updateChannelOpenAIBalance(ch) + require.Error(t, err) + require.Contains(t, err.Error(), "status code: 403") + require.Equal(t, float64(0), balance) + + var fresh model.Channel + require.NoError(t, model.DB.First(&fresh, ch.Id).Error) + require.Equal(t, float64(0), fresh.Balance) +} + +func TestUpdateChannelOpenAIBalance_BadJSON(t *testing.T) { + _ = openTokenControllerTestDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `not-json-garbage`) + })) + defer ts.Close() + + ch := buildOpenAIChannelWithAdminKey(t, ts.URL, "sk-admin-test") + + balance, err := updateChannelOpenAIBalance(ch) + require.Error(t, err) + require.Contains(t, err.Error(), "parse openai usage") + require.Equal(t, float64(0), balance) +} + +func TestUpdateChannelOpenAIBalance_StartTimeIsFirstOfMonthUTC(t *testing.T) { + _ = openTokenControllerTestDB(t) + + var got url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"object":"page","data":[],"has_more":false,"next_page":""}`) + })) + defer ts.Close() + + ch := buildOpenAIChannelWithAdminKey(t, ts.URL, "sk-admin-test") + _, err := updateChannelOpenAIBalance(ch) + require.NoError(t, err) + + now := time.Now().UTC() + wantStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Unix() + require.Equal(t, strconv.FormatInt(wantStart, 10), got.Get("start_time")) + + endStr := got.Get("end_time") + require.NotEmpty(t, endStr) + endParsed, err := strconv.ParseInt(endStr, 10, 64) + require.NoError(t, err) + require.GreaterOrEqual(t, endParsed, wantStart) + require.LessOrEqual(t, endParsed-now.Unix(), int64(5)) + + require.Equal(t, "31", got.Get("limit")) +} + +func TestUpdateChannelOpenAIBalance_PrepaidRemaining(t *testing.T) { + _ = openTokenControllerTestDB(t) + + var capturedQuery url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{ + "object": "page", + "data": [{"object": "bucket","start_time": 0,"end_time": 0, + "results": [{"object": "organization.costs.result","amount": {"value": 3.25,"currency": "usd"}}]}], + "has_more": false, + "next_page": "" + }`) + })) + defer ts.Close() + + settings := dto.ChannelOtherSettings{ + OpenAIAdminKey: "sk-admin-test", + OpenAIPrepaidAmount: 20.0, + OpenAIPrepaidSince: 1700000000, + } + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + encoded, _ := common.Marshal(settings) + bURL := ts.URL + ch := &model.Channel{ + Type: constant.ChannelTypeOpenAI, Key: "sk-fake", + Status: 1, Name: "test", BaseURL: &bURL, + OtherSettings: string(encoded), + } + require.NoError(t, model.DB.Create(ch).Error) + + balance, err := updateChannelOpenAIBalance(ch) + require.NoError(t, err) + require.InDelta(t, 16.75, balance, 0.001) // 20.0 - 3.25 remaining + require.Equal(t, "1700000000", capturedQuery.Get("start_time")) +} + +func TestUpdateChannelOpenAIBalance_PrepaidExhausted(t *testing.T) { + _ = openTokenControllerTestDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{ + "object": "page", + "data": [{"object": "bucket","start_time": 0,"end_time": 0, + "results": [{"object": "organization.costs.result","amount": {"value": 25.0,"currency": "usd"}}]}], + "has_more": false, + "next_page": "" + }`) + })) + defer ts.Close() + + settings := dto.ChannelOtherSettings{ + OpenAIAdminKey: "sk-admin-test", OpenAIPrepaidAmount: 20.0, OpenAIPrepaidSince: 1700000000, + } + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + encoded, _ := common.Marshal(settings) + bURL := ts.URL + ch := &model.Channel{ + Type: constant.ChannelTypeOpenAI, Key: "sk-fake", + Status: 1, Name: "test", BaseURL: &bURL, + OtherSettings: string(encoded), + } + require.NoError(t, model.DB.Create(ch).Error) + + balance, err := updateChannelOpenAIBalance(ch) + require.NoError(t, err) + require.Equal(t, float64(0), balance) // cost > prepaid: clamp to 0 +} + +func TestUpdateChannelOpenAIBalance_PrepaidPartialUnsetFallsBackToMTD(t *testing.T) { + _ = openTokenControllerTestDB(t) + + var capturedQuery url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{ + "object": "page", + "data": [], + "has_more": false, + "next_page": "" + }`) + })) + defer ts.Close() + + // Only prepaid_amount set, NOT prepaid_since — should fall back to MTD + settings := dto.ChannelOtherSettings{ + OpenAIAdminKey: "sk-admin-test", OpenAIPrepaidAmount: 20.0, // OpenAIPrepaidSince: 0 (unset) + } + require.NoError(t, model.DB.AutoMigrate(&model.Channel{})) + encoded, _ := common.Marshal(settings) + bURL := ts.URL + ch := &model.Channel{ + Type: constant.ChannelTypeOpenAI, Key: "sk-fake", + Status: 1, Name: "test", BaseURL: &bURL, + OtherSettings: string(encoded), + } + require.NoError(t, model.DB.Create(ch).Error) + + _, err := updateChannelOpenAIBalance(ch) + require.NoError(t, err) + // start_time should be 1st of current month UTC (fallback path) + require.Equal(t, strconv.FormatInt(firstDayOfCurrentMonthUTC(), 10), capturedQuery.Get("start_time")) +} + +// TestChannelClean_RemovesOpenAIAdminKey verifies that Channel.Clean zeroes out the inference +// Key column AND the OpenAIAdminKey nested in OtherSettings JSON. This is the core masking +// guarantee that prevents the admin key from being exposed in GET /api/channel/ responses +// or in the channel list / search payloads. +func TestChannelClean_RemovesOpenAIAdminKey(t *testing.T) { + _ = openTokenControllerTestDB(t) + settingsBytes, err := common.Marshal(dto.ChannelOtherSettings{OpenAIAdminKey: "sk-admin-secret"}) + require.NoError(t, err) + bURL := "http://x" + ch := &model.Channel{ + Type: constant.ChannelTypeOpenAI, + Key: "sk-secret", + Status: 1, + BaseURL: &bURL, + OtherSettings: string(settingsBytes), + } + + ch.Clean() + + require.Equal(t, "", ch.Key, "inference key must be masked") + other := ch.GetOtherSettings() + require.Equal(t, "", other.OpenAIAdminKey, "openai admin key must be stripped from OtherSettings") +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go index b6a1ab9f713..ecbdadeb6f9 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -35,6 +35,9 @@ type ChannelOtherSettings struct { DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` + OpenAIAdminKey string `json:"openai_admin_key,omitempty"` + OpenAIPrepaidAmount float64 `json:"openai_prepaid_amount,omitempty"` + OpenAIPrepaidSince int64 `json:"openai_prepaid_since,omitempty"` UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间 diff --git a/model/channel.go b/model/channel.go index 3e6d1866a09..6ab9c4fdd83 100644 --- a/model/channel.go +++ b/model/channel.go @@ -961,6 +961,20 @@ func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) { channel.OtherSettings = string(settingBytes) } +// Clean strips sensitive fields from the channel so it is safe to serialize back to API clients. +// Currently masks the inference Key and the OpenAIAdminKey nested in OtherSettings. +// Callers should invoke Clean (or controller-layer helpers that wrap it) before JSON-encoding +// a channel in any response that is not an explicit "reveal secret" endpoint. +func (channel *Channel) Clean() { + channel.Key = "" + // Also strip admin keys from other_settings JSON so they're never exposed in API responses. + other := channel.GetOtherSettings() + if other.OpenAIAdminKey != "" { + other.OpenAIAdminKey = "" + channel.SetOtherSettings(other) + } +} + func (channel *Channel) GetParamOverride() map[string]interface{} { paramOverride := make(map[string]interface{}) if channel.ParamOverride != nil && *channel.ParamOverride != "" { diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 39a6e1527b5..0aa5da5ac2a 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -2005,6 +2005,88 @@ export function ChannelMutateDrawer({ }} /> + {currentType === 1 && ( + { + const adminKeyPlaceholder = isEditing + ? t('Leave empty to keep existing admin key') + : 'sk-admin-...' + return ( + + {t('OpenAI Admin Key')} + +