Commit 2b0efd84 by Seefs Committed by GitHub

refactor: advanced custom channel route editor (#6865)

* refactor: advanced custom channel route editor

* fix(channels): show raw balance response from balance cell
parent 3dda1d50
...@@ -22,6 +22,14 @@ func Marshal(v any) ([]byte, error) { ...@@ -22,6 +22,14 @@ func Marshal(v any) ([]byte, error) {
return json.Marshal(v) return json.Marshal(v)
} }
func IndentJson(data []byte) ([]byte, error) {
var buffer bytes.Buffer
if err := json.Indent(&buffer, data, "", " "); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func GetJsonType(data json.RawMessage) string { func GetJsonType(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data) trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 { if len(trimmed) == 0 {
......
...@@ -5,13 +5,19 @@ import ( ...@@ -5,13 +5,19 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
...@@ -47,6 +53,13 @@ type OpenAICreditGrants struct { ...@@ -47,6 +53,13 @@ type OpenAICreditGrants struct {
TotalAvailable float64 `json:"total_available"` TotalAvailable float64 `json:"total_available"`
} }
const maxAdvancedCustomBalanceResponseBytes = 256 << 10
type channelBalanceResult struct {
Balance float64
RawResponse string
}
type OpenAIUsageResponse struct { type OpenAIUsageResponse struct {
Object string `json:"object"` Object string `json:"object"`
//DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` //DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"`
...@@ -174,7 +187,7 @@ func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { ...@@ -174,7 +187,7 @@ func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := OpenAICreditGrants{} response := OpenAICreditGrants{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -189,7 +202,7 @@ func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { ...@@ -189,7 +202,7 @@ func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := OpenAISBUsageResponse{} response := OpenAISBUsageResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -213,7 +226,7 @@ func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) { ...@@ -213,7 +226,7 @@ func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := AIProxyUserOverviewResponse{} response := AIProxyUserOverviewResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -232,7 +245,7 @@ func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) { ...@@ -232,7 +245,7 @@ func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := API2GPTUsageResponse{} response := API2GPTUsageResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -247,7 +260,7 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { ...@@ -247,7 +260,7 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := SiliconFlowUsageResponse{} response := SiliconFlowUsageResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -269,7 +282,7 @@ func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { ...@@ -269,7 +282,7 @@ func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := DeepSeekUsageResponse{} response := DeepSeekUsageResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -298,7 +311,7 @@ func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { ...@@ -298,7 +311,7 @@ func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := APGC2DGPTUsageResponse{} response := APGC2DGPTUsageResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -313,7 +326,7 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { ...@@ -313,7 +326,7 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
response := OpenRouterCreditResponse{} response := OpenRouterCreditResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -343,7 +356,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { ...@@ -343,7 +356,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
} }
response := MoonshotBalanceResponse{} response := MoonshotBalanceResponse{}
err = json.Unmarshal(body, &response) err = common.Unmarshal(body, &response)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -356,7 +369,100 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { ...@@ -356,7 +369,100 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return availableBalanceUsd, nil return availableBalanceUsd, nil
} }
func updateChannelBalance(channel *model.Channel) (float64, error) { func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) {
key := strings.TrimSpace(channel.Key)
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
RelayMode: relayconstant.RelayModeUnknown,
RequestURLPath: dto.AdvancedCustomBalancePath,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeAdvancedCustom,
ChannelBaseUrl: channel.GetBaseURL(),
ApiKey: key,
ChannelOtherSettings: channel.GetOtherSettings(),
},
}
requestURL, headers, err := (&advancedcustom.Adaptor{}).BuildBalanceRequest(info)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
if err := applyFetchModelsHeaderOverrides(channel, key, headers); err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
for name, values := range headers {
for _, value := range values {
request.Header.Add(name, value)
}
if strings.EqualFold(name, "Host") {
request.Host = headers.Get(name)
}
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
response, err := client.Do(request)
if err != nil {
return channelBalanceResult{}, sanitizeAdvancedCustomRequestError(err, key, requestURL)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return channelBalanceResult{}, fmt.Errorf("status code: %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxAdvancedCustomBalanceResponseBytes+1))
if err != nil {
return channelBalanceResult{}, sanitizeAdvancedCustomRequestError(err, key, requestURL)
}
if len(body) > maxAdvancedCustomBalanceResponseBytes {
return channelBalanceResult{}, fmt.Errorf("balance response exceeds %d bytes", maxAdvancedCustomBalanceResponseBytes)
}
var validated json.RawMessage
if err := common.Unmarshal(body, &validated); err != nil {
return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
}
if common.GetJsonType(validated) == "object" {
var creditSummary struct {
Object string `json:"object"`
TotalAvailable json.RawMessage `json:"total_available"`
}
if err := common.Unmarshal(body, &creditSummary); err != nil {
return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
}
if creditSummary.Object == "credit_summary" &&
common.GetJsonType(creditSummary.TotalAvailable) == "number" {
var balance float64
if err := common.Unmarshal(creditSummary.TotalAvailable, &balance); err == nil &&
balance >= 0 &&
!math.IsNaN(balance) &&
!math.IsInf(balance, 0) {
channel.UpdateBalance(balance)
return channelBalanceResult{Balance: balance}, nil
}
}
}
formatted, err := common.IndentJson(body)
if err != nil {
return channelBalanceResult{}, fmt.Errorf("invalid balance JSON response: %w", err)
}
return channelBalanceResult{RawResponse: string(formatted)}, nil
}
func updateChannelBalance(channel *model.Channel) (channelBalanceResult, error) {
if channel.Type == constant.ChannelTypeAdvancedCustom {
return fetchAdvancedCustomBalance(channel)
}
balance, err := updateStandardChannelBalance(channel)
return channelBalanceResult{Balance: balance}, err
}
func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
baseURL := constant.ChannelBaseURLs[channel.Type] baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() == "" { if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL channel.BaseURL = &baseURL
...@@ -396,7 +502,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { ...@@ -396,7 +502,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
subscription := OpenAISubscriptionResponse{} subscription := OpenAISubscriptionResponse{}
err = json.Unmarshal(body, &subscription) err = common.Unmarshal(body, &subscription)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -412,7 +518,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { ...@@ -412,7 +518,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return 0, err return 0, err
} }
usage := OpenAIUsageResponse{} usage := OpenAIUsageResponse{}
err = json.Unmarshal(body, &usage) err = common.Unmarshal(body, &usage)
if err != nil { if err != nil {
return 0, err return 0, err
} }
...@@ -439,16 +545,21 @@ func UpdateChannelBalance(c *gin.Context) { ...@@ -439,16 +545,21 @@ func UpdateChannelBalance(c *gin.Context) {
}) })
return return
} }
balance, err := updateChannelBalance(channel) result, err := updateChannelBalance(channel)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{ response := gin.H{
"success": true, "success": true,
"message": "", "message": "",
"balance": balance, }
}) if result.RawResponse == "" {
response["balance"] = result.Balance
} else {
response["raw_response"] = result.RawResponse
}
c.JSON(http.StatusOK, response)
} }
func updateAllChannelsBalance() error { func updateAllChannelsBalance() error {
...@@ -467,12 +578,12 @@ func updateAllChannelsBalance() error { ...@@ -467,12 +578,12 @@ func updateAllChannelsBalance() error {
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom { //if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
// continue // continue
//} //}
balance, err := updateChannelBalance(channel) result, err := updateChannelBalance(channel)
if err != nil { if err != nil {
continue continue
} else { } else if result.RawResponse == "" {
// err is nil & balance <= 0 means quota is used up // err is nil & balance <= 0 means quota is used up
if balance <= 0 { if result.Balance <= 0 {
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足") service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
} }
} }
......
...@@ -304,6 +304,34 @@ func sanitizeFetchModelsError(err error, key string) error { ...@@ -304,6 +304,34 @@ func sanitizeFetchModelsError(err error, key string) error {
return errors.New(message) return errors.New(message)
} }
func sanitizeAdvancedCustomRequestError(err error, key string, requestURL string) error {
err = sanitizeFetchModelsError(err, key)
if err == nil {
return nil
}
parsedURL, parseErr := url.Parse(requestURL)
if parseErr != nil {
return err
}
message := err.Error()
for _, value := range parsedURL.Query() {
for _, secret := range value {
if secret == "" {
continue
}
message = strings.ReplaceAll(message, secret, "[REDACTED]")
message = strings.ReplaceAll(message, url.QueryEscape(secret), "[REDACTED]")
message = strings.ReplaceAll(message, url.PathEscape(secret), "[REDACTED]")
}
}
if key != "" {
message = strings.ReplaceAll(message, key, "[REDACTED]")
message = strings.ReplaceAll(message, url.QueryEscape(key), "[REDACTED]")
message = strings.ReplaceAll(message, url.PathEscape(key), "[REDACTED]")
}
return errors.New(message)
}
func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) { func getFetchModelsResponseBody(method string, requestURL string, channel *model.Channel, headers http.Header) ([]byte, error) {
request, err := http.NewRequest(method, requestURL, nil) request, err := http.NewRequest(method, requestURL, nil)
if err != nil { if err != nil {
...@@ -409,7 +437,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { ...@@ -409,7 +437,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers) body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers)
if err != nil { if err != nil {
return nil, sanitizeFetchModelsError(err, key) return nil, sanitizeAdvancedCustomRequestError(err, key, url)
} }
var result OpenAIModelsResponse var result OpenAIModelsResponse
......
...@@ -168,6 +168,15 @@ func TestFetchAdvancedCustomModelsRedactsQueryKeyFromTransportErrors(t *testing. ...@@ -168,6 +168,15 @@ func TestFetchAdvancedCustomModelsRedactsQueryKeyFromTransportErrors(t *testing.
Err: errors.New("connection refused"), Err: errors.New("connection refused"),
}, secret) }, secret)
require.EqualError(t, direct, "connection refused") require.EqualError(t, direct, "connection refused")
queryValue := "prefix-" + secret
queryError := sanitizeAdvancedCustomRequestError(
errors.New("dial "+queryValue+": connection refused"),
queryValue,
baseURL+"/v1/models?custom-token="+url.QueryEscape(queryValue),
)
require.NotContains(t, queryError.Error(), queryValue)
require.EqualError(t, queryError, "dial [REDACTED]: connection refused")
} }
func TestFetchOrdinaryOpenAIModelsKeepsExistingEmptyDataBehavior(t *testing.T) { func TestFetchOrdinaryOpenAIModelsKeepsExistingEmptyDataBehavior(t *testing.T) {
......
...@@ -194,6 +194,14 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { ...@@ -194,6 +194,14 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
} }
func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, http.Header, error) { func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, http.Header, error) {
return a.buildManagementRequest(info, dto.AdvancedCustomModelListPath)
}
func (a *Adaptor) BuildBalanceRequest(info *relaycommon.RelayInfo) (string, http.Header, error) {
return a.buildManagementRequest(info, dto.AdvancedCustomBalancePath)
}
func (a *Adaptor) buildManagementRequest(info *relaycommon.RelayInfo, managementPath string) (string, http.Header, error) {
if info == nil { if info == nil {
return "", nil, errors.New("missing relay info") return "", nil, errors.New("missing relay info")
} }
...@@ -204,16 +212,25 @@ func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, ht ...@@ -204,16 +212,25 @@ func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, ht
if err := config.Validate(); err != nil { if err := config.Validate(); err != nil {
return "", nil, err return "", nil, err
} }
route, ok := config.ModelListRoute() var route dto.AdvancedCustomRoute
var ok bool
switch managementPath {
case dto.AdvancedCustomModelListPath:
route, ok = config.ModelListRoute()
case dto.AdvancedCustomBalancePath:
route, ok = config.BalanceRoute()
default:
return "", nil, fmt.Errorf("unsupported advanced custom management path: %s", managementPath)
}
if !ok { if !ok {
return "", nil, errors.New("advanced custom channel does not configure a /v1/models route") return "", nil, fmt.Errorf("advanced custom channel does not configure a %s route", managementPath)
} }
converter := strings.TrimSpace(route.Converter) converter := strings.TrimSpace(route.Converter)
if converter == "" { if converter == "" {
converter = relayconvert.ConverterNone converter = relayconvert.ConverterNone
} }
if converter != relayconvert.ConverterNone { if converter != relayconvert.ConverterNone {
return "", nil, fmt.Errorf("converter %q does not support model list requests", converter) return "", nil, fmt.Errorf("converter %q does not support %s requests", converter, managementPath)
} }
requestURL, err := buildRouteURL(route, converter, info) requestURL, err := buildRouteURL(route, converter, info)
......
...@@ -422,6 +422,49 @@ func TestAdaptorBuildModelListRequestRequiresConfiguredRoute(t *testing.T) { ...@@ -422,6 +422,49 @@ func TestAdaptorBuildModelListRequestRequiresConfiguredRoute(t *testing.T) {
assert.Contains(t, err.Error(), "does not configure a /v1/models route") assert.Contains(t, err.Error(), "does not configure a /v1/models route")
} }
func TestAdaptorBuildBalanceRequestUsesConfiguredRoute(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: "/provider/models",
},
{
IncomingPath: dto.AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance?existing=1",
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "token",
Value: "prefix-{api_key}",
},
},
},
})
requestURL, header, err := (&Adaptor{}).BuildBalanceRequest(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "/provider/balance", parsedURL.Path)
assert.Equal(t, "1", parsedURL.Query().Get("existing"))
assert.Equal(t, "prefix-sk-test", parsedURL.Query().Get("token"))
assert.Empty(t, header.Get("Authorization"))
}
func TestAdaptorBuildBalanceRequestRequiresConfiguredRoute(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: "/provider/models",
}},
})
_, _, err := (&Adaptor{}).BuildBalanceRequest(info)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not configure a /v1/dashboard/billing/credit_grants route")
}
func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) { func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
adaptor := &Adaptor{} adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
......
...@@ -145,8 +145,12 @@ const ( ...@@ -145,8 +145,12 @@ const (
advancedCustomEndpointPathEmbeddings = "/v1/embeddings" advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
) )
// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route. const (
const AdvancedCustomModelListPath = "/v1/models" // AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route.
AdvancedCustomModelListPath = "/v1/models"
// AdvancedCustomBalancePath identifies the optional balance lookup route used by channel management.
AdvancedCustomBalancePath = "/v1/dashboard/billing/credit_grants"
)
// MatchPath returns the first route whose IncomingPath matches requestPath. // MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and // Matching mirrors the relay adaptor: exact match, {model} placeholder, and
...@@ -193,6 +197,19 @@ func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) { ...@@ -193,6 +197,19 @@ func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) {
return AdvancedCustomRoute{}, false return AdvancedCustomRoute{}, false
} }
// BalanceRoute returns the explicitly configured channel-management balance route.
func (c *AdvancedCustomConfig) BalanceRoute() (AdvancedCustomRoute, bool) {
if c == nil {
return AdvancedCustomRoute{}, false
}
for _, route := range c.Routes {
if strings.TrimSpace(route.IncomingPath) == AdvancedCustomBalancePath {
return route, true
}
}
return AdvancedCustomRoute{}, false
}
// SupportsPath reports whether any route matches requestPath. // SupportsPath reports whether any route matches requestPath.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool { func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath) _, ok := c.MatchPath(requestPath)
...@@ -360,6 +377,7 @@ func (c *AdvancedCustomConfig) Validate() error { ...@@ -360,6 +377,7 @@ func (c *AdvancedCustomConfig) Validate() error {
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes)) paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
modelListRouteIndex := -1 modelListRouteIndex := -1
balanceRouteIndex := -1
for i := range c.Routes { for i := range c.Routes {
route := c.Routes[i] route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath) route.IncomingPath = strings.TrimSpace(route.IncomingPath)
...@@ -378,19 +396,28 @@ func (c *AdvancedCustomConfig) Validate() error { ...@@ -378,19 +396,28 @@ func (c *AdvancedCustomConfig) Validate() error {
if strings.Contains(route.IncomingPath, "?") { if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i) return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
} }
if route.IncomingPath == AdvancedCustomModelListPath { if route.IncomingPath == AdvancedCustomModelListPath || route.IncomingPath == AdvancedCustomBalancePath {
if modelListRouteIndex >= 0 { managementRouteName := route.IncomingPath
return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the /v1/models route at advanced_routes[%d]", i, modelListRouteIndex) previousIndex := modelListRouteIndex
if route.IncomingPath == AdvancedCustomBalancePath {
previousIndex = balanceRouteIndex
}
if previousIndex >= 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the %s route at advanced_routes[%d]", i, managementRouteName, previousIndex)
}
if route.IncomingPath == AdvancedCustomModelListPath {
modelListRouteIndex = i
} else {
balanceRouteIndex = i
} }
modelListRouteIndex = i
if len(normalizeAdvancedCustomRouteModels(route.Models)) > 0 { if len(normalizeAdvancedCustomRouteModels(route.Models)) > 0 {
return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for /v1/models", i) return fmt.Errorf("advanced_custom.advanced_routes[%d].models must be empty for %s", i, managementRouteName)
} }
if route.Converter != advancedCustomConverterNone { if route.Converter != advancedCustomConverterNone {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for /v1/models", i) return fmt.Errorf("advanced_custom.advanced_routes[%d].converter must be none for %s", i, managementRouteName)
} }
if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) { if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for /v1/models", i, advancedCustomModelPlaceholder) return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for %s", i, advancedCustomModelPlaceholder, managementRouteName)
} }
} }
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil { if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
......
...@@ -147,6 +147,70 @@ func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) { ...@@ -147,6 +147,70 @@ func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) {
assert.Equal(t, "/provider/models", route.UpstreamPath) assert.Equal(t, "/provider/models", route.UpstreamPath)
} }
func TestAdvancedCustomValidateBalanceRouteConstraints(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Converter: advancedCustomConverterNone,
}},
}
require.NoError(t, valid.Validate())
route, ok := valid.BalanceRoute()
require.True(t, ok)
assert.Equal(t, "/provider/balance", route.UpstreamPath)
tests := []struct {
name string
routes []AdvancedCustomRoute
want string
}{
{
name: "model matching rules",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Models: []string{"gpt-4o"},
}},
want: "models must be empty",
},
{
name: "converter",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
Converter: advancedCustomConverterOpenAIChatToOpenAIResponses,
}},
want: "converter must be none",
},
{
name: "model placeholder",
routes: []AdvancedCustomRoute{{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/{model}/balance",
}},
want: "upstream_path must not contain {model}",
},
{
name: "duplicate routes",
routes: []AdvancedCustomRoute{
{IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/balance"},
{IncomingPath: AdvancedCustomBalancePath, UpstreamPath: "/provider/credits"},
},
want: "duplicates the /v1/dashboard/billing/credit_grants route",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := (&AdvancedCustomConfig{Routes: tt.routes}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) { func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
config := &AdvancedCustomConfig{ config := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{ Routes: []AdvancedCustomRoute{
......
...@@ -55,7 +55,7 @@ import { ...@@ -55,7 +55,7 @@ import {
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { truncateText } from '@/lib/utils' import { truncateText } from '@/lib/utils'
import { getCodexUsage } from '../api' import { getCodexUsage, updateChannelBalance } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants' import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
import { import {
formatRelativeTime, formatRelativeTime,
...@@ -68,9 +68,9 @@ import { ...@@ -68,9 +68,9 @@ import {
parseModelsList, parseModelsList,
parseGroupsList, parseGroupsList,
parseChannelSettings, parseChannelSettings,
channelsQueryKeys,
handleUpdateChannelField, handleUpdateChannelField,
handleUpdateTagField, handleUpdateTagField,
handleUpdateChannelBalance,
createChannelFieldUpdateScheduler, createChannelFieldUpdateScheduler,
isTagAggregateRow, isTagAggregateRow,
type TagRow, type TagRow,
...@@ -81,6 +81,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' ...@@ -81,6 +81,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider' import { useChannels } from './channels-provider'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
import { DataTableTagRowActions } from './data-table-tag-row-actions' import { DataTableTagRowActions } from './data-table-tag-row-actions'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import { import {
CodexUsageDialog, CodexUsageDialog,
type CodexUsageDialogData, type CodexUsageDialogData,
...@@ -325,15 +326,18 @@ const SENSITIVE_MASK = '••••' ...@@ -325,15 +326,18 @@ const SENSITIVE_MASK = '••••'
/** /**
* Balance cell component with click to update * Balance cell component with click to update
*/ */
function BalanceCell({ channel }: { channel: Channel }) { export function BalanceCell({ channel }: { channel: Channel }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const layout = useContext(ChannelRowActionsLayoutContext) const layout = useContext(ChannelRowActionsLayoutContext)
const { sensitiveVisible } = useChannels() const { sensitiveVisible, setCurrentRow } = useChannels()
const isTagRow = isTagAggregateRow(channel) const isTagRow = isTagAggregateRow(channel)
const balance = channel.balance || 0 const balance = channel.balance || 0
const usedQuota = channel.used_quota || 0 const usedQuota = channel.used_quota || 0
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
const [rawBalanceResponse, setRawBalanceResponse] = useState<string | null>(
null
)
const [codexUsageOpen, setCodexUsageOpen] = useState(false) const [codexUsageOpen, setCodexUsageOpen] = useState(false)
const [codexUsageResponse, setCodexUsageResponse] = const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null) useState<CodexUsageDialogData | null>(null)
...@@ -442,8 +446,34 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -442,8 +446,34 @@ function BalanceCell({ channel }: { channel: Channel }) {
return return
} }
await handleUpdateChannelBalance(channel.id, queryClient) try {
setIsUpdating(false) const response = await updateChannelBalance(channel.id)
if (response.success && response.balance !== undefined) {
toast.success(
t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(response.balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
void queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
} else if (response.success && response.raw_response !== undefined) {
setCurrentRow(channel)
setRawBalanceResponse(response.raw_response)
} else {
toast.error(response.message || t('Failed to update balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update balance')
)
} finally {
setIsUpdating(false)
}
} }
let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK let remainingBadgeLabel = sensitiveVisible ? remainingDisplay : SENSITIVE_MASK
if (sensitiveVisible && isUpdating) { if (sensitiveVisible && isUpdating) {
...@@ -536,6 +566,17 @@ function BalanceCell({ channel }: { channel: Channel }) { ...@@ -536,6 +566,17 @@ function BalanceCell({ channel }: { channel: Channel }) {
}} }}
isRefreshing={isUpdating} isRefreshing={isUpdating}
/> />
{rawBalanceResponse !== null && (
<BalanceQueryDialog
initialRawResponse={rawBalanceResponse}
open
onOpenChange={(open) => {
if (!open) {
setRawBalanceResponse(null)
}
}}
/>
)}
</TooltipProvider> </TooltipProvider>
) )
} }
......
...@@ -22,7 +22,12 @@ import { useEffect, useState } from 'react' ...@@ -22,7 +22,12 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge' import { IconBadge } from '@/components/ui/icon-badge'
import { formatCurrencyFromUSD } from '@/lib/currency' import { formatCurrencyFromUSD } from '@/lib/currency'
...@@ -37,14 +42,12 @@ import { ...@@ -37,14 +42,12 @@ import {
} from './codex-usage-dialog' } from './codex-usage-dialog'
type BalanceQueryDialogProps = { type BalanceQueryDialogProps = {
initialRawResponse?: string
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
export function BalanceQueryDialog({ export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
open,
onOpenChange,
}: BalanceQueryDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { currentRow, setCurrentRow } = useChannels() const { currentRow, setCurrentRow } = useChannels()
const queryClient = useQueryClient() const queryClient = useQueryClient()
...@@ -53,6 +56,9 @@ export function BalanceQueryDialog({ ...@@ -53,6 +56,9 @@ export function BalanceQueryDialog({
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>( const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
null null
) )
const [rawResponse, setRawResponse] = useState<string | null>(
props.initialRawResponse ?? null
)
const [codexUsageResponse, setCodexUsageResponse] = const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null) useState<CodexUsageDialogData | null>(null)
...@@ -79,10 +85,10 @@ export function BalanceQueryDialog({ ...@@ -79,10 +85,10 @@ export function BalanceQueryDialog({
useEffect(() => { useEffect(() => {
if (!isCodex) return if (!isCodex) return
if (!open) return if (!props.open) return
handleQueryCodexUsage() handleQueryCodexUsage()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isCodex]) }, [props.open, isCodex])
if (!currentRow) return null if (!currentRow) return null
...@@ -109,6 +115,9 @@ export function BalanceQueryDialog({ ...@@ -109,6 +115,9 @@ export function BalanceQueryDialog({
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(), queryKey: channelsQueryKeys.lists(),
}) })
setRawResponse(null)
} else if (response.success && response.raw_response !== undefined) {
setRawResponse(response.raw_response)
} else { } else {
toast.error(response.message || t('Failed to query balance')) toast.error(response.message || t('Failed to query balance'))
} }
...@@ -124,8 +133,9 @@ export function BalanceQueryDialog({ ...@@ -124,8 +133,9 @@ export function BalanceQueryDialog({
const handleClose = () => { const handleClose = () => {
setBalance(null) setBalance(null)
setBalanceUpdatedTime(null) setBalanceUpdatedTime(null)
setRawResponse(null)
setCodexUsageResponse(null) setCodexUsageResponse(null)
onOpenChange(false) props.onOpenChange(false)
} }
const formatBalance = (bal: number) => const formatBalance = (bal: number) =>
...@@ -143,7 +153,7 @@ export function BalanceQueryDialog({ ...@@ -143,7 +153,7 @@ export function BalanceQueryDialog({
if (isCodex) { if (isCodex) {
return ( return (
<CodexUsageDialog <CodexUsageDialog
open={open} open={props.open}
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) handleClose() if (!v) handleClose()
}} }}
...@@ -158,7 +168,7 @@ export function BalanceQueryDialog({ ...@@ -158,7 +168,7 @@ export function BalanceQueryDialog({
return ( return (
<Dialog <Dialog
open={open} open={props.open}
onOpenChange={handleClose} onOpenChange={handleClose}
title={t('Query Balance')} title={t('Query Balance')}
description={ description={
...@@ -176,24 +186,50 @@ export function BalanceQueryDialog({ ...@@ -176,24 +186,50 @@ export function BalanceQueryDialog({
} }
> >
<div className='space-y-4 py-4'> <div className='space-y-4 py-4'>
{/* Current Balance Display */} {rawResponse !== null ? (
<div className='bg-muted/50 rounded-lg border p-4'> <>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'> <Alert>
<IconBadge tone='success' size='xs'> <AlertTitle>{t('Balance response not recognized')}</AlertTitle>
<DollarSign /> <AlertDescription>
</IconBadge> {t(
<span>{t('Current Balance')}</span> 'The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.'
</div> )}
<div className='text-2xl font-bold'> </AlertDescription>
{balance !== null </Alert>
? formatBalance(balance) <CodeBlock
: formatBalance(currentRow.balance)} code={rawResponse}
</div> language='json'
<div className='text-muted-foreground mt-2 text-xs'> maxExpandedLines={24}
{t('Last updated:')}{' '} showLineNumbers
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)} title={t('Upstream JSON response')}
</div> >
</div> <CodeBlockCopyButton />
</CodeBlock>
</>
) : (
<>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<IconBadge tone='success' size='xs'>
<DollarSign />
</IconBadge>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
</>
)}
{/* Balance Update Button */} {/* Balance Update Button */}
<Button <Button
......
...@@ -20,8 +20,6 @@ import type { QueryClient } from '@tanstack/react-query' ...@@ -20,8 +20,6 @@ import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next' import i18next from 'i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { import {
copyChannel, copyChannel,
deleteChannel, deleteChannel,
...@@ -38,7 +36,6 @@ import { ...@@ -38,7 +36,6 @@ import {
editTagChannels, editTagChannels,
testAllChannels, testAllChannels,
updateAllChannelsBalance, updateAllChannelsBalance,
updateChannelBalance,
} from '../api' } from '../api'
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import type { ChannelTestResponse, CopyChannelParams } from '../types' import type { ChannelTestResponse, CopyChannelParams } from '../types'
...@@ -362,41 +359,6 @@ export async function handleCopyChannel( ...@@ -362,41 +359,6 @@ export async function handleCopyChannel(
} }
} }
/**
* Update channel balance
*/
export async function handleUpdateChannelBalance(
id: number,
queryClient?: QueryClient,
onSuccess?: (balance: number) => void
): Promise<void> {
try {
const response = await updateChannelBalance(id)
if (response.success && response.balance !== undefined) {
const balance = response.balance
toast.success(
i18next.t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(balance)
} else {
toast.error(response.message || i18next.t('Failed to update balance'))
}
} catch (_error: unknown) {
toast.error(
_error instanceof Error
? _error.message
: i18next.t('Failed to update balance')
)
}
}
// ============================================================================ // ============================================================================
// Batch Actions // Batch Actions
// ============================================================================ // ============================================================================
......
...@@ -197,6 +197,7 @@ export interface ChannelBalanceResponse { ...@@ -197,6 +197,7 @@ export interface ChannelBalanceResponse {
message?: string message?: string
balance?: number balance?: number
currency?: string currency?: string
raw_response?: string
} }
export interface FetchModelsResponse { export interface FetchModelsResponse {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment