From 2b0efd8484cc1e20b6de64f8600586fe61dee867 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:31:21 +0800 Subject: [PATCH] refactor: advanced custom channel route editor (#6865) * refactor: advanced custom channel route editor * fix(channels): show raw balance response from balance cell --- common/json.go | 8 + controller/channel-billing.go | 149 ++- controller/channel_upstream_update.go | 30 +- controller/channel_upstream_update_test.go | 9 + relay/channel/advancedcustom/adaptor.go | 23 +- relay/channel/advancedcustom/adaptor_test.go | 43 + relaykit/dto/channel_settings.go | 45 +- relaykit/dto/channel_settings_test.go | 64 ++ .../channels/components/channels-columns.tsx | 53 +- .../dialogs/advanced-custom-editor-dialog.tsx | 953 ++++++++++++------ .../dialogs/balance-query-dialog.tsx | 90 +- .../features/channels/lib/advanced-custom.ts | 285 ++++-- .../features/channels/lib/channel-actions.ts | 38 - web/src/features/channels/types.ts | 1 + web/src/i18n/locales/en.json | 38 + web/src/i18n/locales/fr.json | 38 + web/src/i18n/locales/ja.json | 38 + web/src/i18n/locales/ru.json | 38 + web/src/i18n/locales/vi.json | 38 + web/src/i18n/locales/zh-TW.json | 38 + web/src/i18n/locales/zh.json | 38 + 21 files changed, 1551 insertions(+), 506 deletions(-) diff --git a/common/json.go b/common/json.go index 1625be6d..d7effa36 100644 --- a/common/json.go +++ b/common/json.go @@ -22,6 +22,14 @@ func Marshal(v any) ([]byte, error) { 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 { trimmed := bytes.TrimSpace(data) if len(trimmed) == 0 { diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 62982d2f..5974628d 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -5,13 +5,19 @@ import ( "errors" "fmt" "io" + "math" "net/http" "strconv" + "strings" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "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/service" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -47,6 +53,13 @@ type OpenAICreditGrants struct { TotalAvailable float64 `json:"total_available"` } +const maxAdvancedCustomBalanceResponseBytes = 256 << 10 + +type channelBalanceResult struct { + Balance float64 + RawResponse string +} + type OpenAIUsageResponse struct { Object string `json:"object"` //DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` @@ -174,7 +187,7 @@ func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { return 0, err } response := OpenAICreditGrants{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -189,7 +202,7 @@ func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { return 0, err } response := OpenAISBUsageResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -213,7 +226,7 @@ func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) { return 0, err } response := AIProxyUserOverviewResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -232,7 +245,7 @@ func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) { return 0, err } response := API2GPTUsageResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -247,7 +260,7 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { return 0, err } response := SiliconFlowUsageResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -269,7 +282,7 @@ func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { return 0, err } response := DeepSeekUsageResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -298,7 +311,7 @@ func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { return 0, err } response := APGC2DGPTUsageResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -313,7 +326,7 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { return 0, err } response := OpenRouterCreditResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -343,7 +356,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { } response := MoonshotBalanceResponse{} - err = json.Unmarshal(body, &response) + err = common.Unmarshal(body, &response) if err != nil { return 0, err } @@ -356,7 +369,100 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { 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] if channel.GetBaseURL() == "" { channel.BaseURL = &baseURL @@ -396,7 +502,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { return 0, err } subscription := OpenAISubscriptionResponse{} - err = json.Unmarshal(body, &subscription) + err = common.Unmarshal(body, &subscription) if err != nil { return 0, err } @@ -412,7 +518,7 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { return 0, err } usage := OpenAIUsageResponse{} - err = json.Unmarshal(body, &usage) + err = common.Unmarshal(body, &usage) if err != nil { return 0, err } @@ -439,16 +545,21 @@ func UpdateChannelBalance(c *gin.Context) { }) return } - balance, err := updateChannelBalance(channel) + result, err := updateChannelBalance(channel) if err != nil { common.ApiError(c, err) return } - c.JSON(http.StatusOK, gin.H{ + response := gin.H{ "success": true, "message": "", - "balance": balance, - }) + } + if result.RawResponse == "" { + response["balance"] = result.Balance + } else { + response["raw_response"] = result.RawResponse + } + c.JSON(http.StatusOK, response) } func updateAllChannelsBalance() error { @@ -467,12 +578,12 @@ func updateAllChannelsBalance() error { //if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom { // continue //} - balance, err := updateChannelBalance(channel) + result, err := updateChannelBalance(channel) if err != nil { continue - } else { + } else if result.RawResponse == "" { // 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()), "余额不足") } } diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 71ab0e53..e1918c25 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -304,6 +304,34 @@ func sanitizeFetchModelsError(err error, key string) error { 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) { request, err := http.NewRequest(method, requestURL, nil) if err != nil { @@ -409,7 +437,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { body, err := getFetchModelsResponseBody(http.MethodGet, url, channel, headers) if err != nil { - return nil, sanitizeFetchModelsError(err, key) + return nil, sanitizeAdvancedCustomRequestError(err, key, url) } var result OpenAIModelsResponse diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index 0a524d70..5cb4fac4 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -168,6 +168,15 @@ func TestFetchAdvancedCustomModelsRedactsQueryKeyFromTransportErrors(t *testing. Err: errors.New("connection refused"), }, secret) 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) { diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go index 74af2d5c..d5f1ed7e 100644 --- a/relay/channel/advancedcustom/adaptor.go +++ b/relay/channel/advancedcustom/adaptor.go @@ -194,6 +194,14 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, 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 { return "", nil, errors.New("missing relay info") } @@ -204,16 +212,25 @@ func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, ht if err := config.Validate(); err != nil { 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 { - 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) if converter == "" { 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) diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go index 5a11a972..85672cbf 100644 --- a/relay/channel/advancedcustom/adaptor_test.go +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -422,6 +422,49 @@ func TestAdaptorBuildModelListRequestRequiresConfiguredRoute(t *testing.T) { 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) { adaptor := &Adaptor{} info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go index d3ede20d..4b4e7191 100644 --- a/relaykit/dto/channel_settings.go +++ b/relaykit/dto/channel_settings.go @@ -145,8 +145,12 @@ const ( advancedCustomEndpointPathEmbeddings = "/v1/embeddings" ) -// AdvancedCustomModelListPath identifies the optional OpenAI Models discovery route. -const AdvancedCustomModelListPath = "/v1/models" +const ( + // 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. // Matching mirrors the relay adaptor: exact match, {model} placeholder, and @@ -193,6 +197,19 @@ func (c *AdvancedCustomConfig) ModelListRoute() (AdvancedCustomRoute, bool) { 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. func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool { _, ok := c.MatchPath(requestPath) @@ -360,6 +377,7 @@ func (c *AdvancedCustomConfig) Validate() error { paths := make(map[string]*advancedCustomPathModelState, len(c.Routes)) modelListRouteIndex := -1 + balanceRouteIndex := -1 for i := range c.Routes { route := c.Routes[i] route.IncomingPath = strings.TrimSpace(route.IncomingPath) @@ -378,19 +396,28 @@ func (c *AdvancedCustomConfig) Validate() error { if strings.Contains(route.IncomingPath, "?") { return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i) } - if route.IncomingPath == AdvancedCustomModelListPath { - if modelListRouteIndex >= 0 { - return fmt.Errorf("advanced_custom.advanced_routes[%d] duplicates the /v1/models route at advanced_routes[%d]", i, modelListRouteIndex) + if route.IncomingPath == AdvancedCustomModelListPath || route.IncomingPath == AdvancedCustomBalancePath { + managementRouteName := route.IncomingPath + 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 { - 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 { - 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) { - 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 { diff --git a/relaykit/dto/channel_settings_test.go b/relaykit/dto/channel_settings_test.go index d482679a..e8498873 100644 --- a/relaykit/dto/channel_settings_test.go +++ b/relaykit/dto/channel_settings_test.go @@ -147,6 +147,70 @@ func TestAdvancedCustomModelListRouteRequiresExactIncomingPath(t *testing.T) { 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) { config := &AdvancedCustomConfig{ Routes: []AdvancedCustomRoute{ diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx index 36dc8f67..ad6fadcd 100644 --- a/web/src/features/channels/components/channels-columns.tsx +++ b/web/src/features/channels/components/channels-columns.tsx @@ -55,7 +55,7 @@ import { import { formatTimestampToDate } from '@/lib/format' import { truncateText } from '@/lib/utils' -import { getCodexUsage } from '../api' +import { getCodexUsage, updateChannelBalance } from '../api' import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants' import { formatRelativeTime, @@ -68,9 +68,9 @@ import { parseModelsList, parseGroupsList, parseChannelSettings, + channelsQueryKeys, handleUpdateChannelField, handleUpdateTagField, - handleUpdateChannelBalance, createChannelFieldUpdateScheduler, isTagAggregateRow, type TagRow, @@ -81,6 +81,7 @@ import { ChannelRowActionsLayoutContext } from './channel-row-actions-context' import { useChannels } from './channels-provider' import { DataTableRowActions } from './data-table-row-actions' import { DataTableTagRowActions } from './data-table-tag-row-actions' +import { BalanceQueryDialog } from './dialogs/balance-query-dialog' import { CodexUsageDialog, type CodexUsageDialogData, @@ -325,15 +326,18 @@ const SENSITIVE_MASK = '••••' /** * Balance cell component with click to update */ -function BalanceCell({ channel }: { channel: Channel }) { +export function BalanceCell({ channel }: { channel: Channel }) { const { t, i18n } = useTranslation() const queryClient = useQueryClient() const layout = useContext(ChannelRowActionsLayoutContext) - const { sensitiveVisible } = useChannels() + const { sensitiveVisible, setCurrentRow } = useChannels() const isTagRow = isTagAggregateRow(channel) const balance = channel.balance || 0 const usedQuota = channel.used_quota || 0 const [isUpdating, setIsUpdating] = useState(false) + const [rawBalanceResponse, setRawBalanceResponse] = useState( + null + ) const [codexUsageOpen, setCodexUsageOpen] = useState(false) const [codexUsageResponse, setCodexUsageResponse] = useState(null) @@ -442,8 +446,34 @@ function BalanceCell({ channel }: { channel: Channel }) { return } - await handleUpdateChannelBalance(channel.id, queryClient) - setIsUpdating(false) + try { + 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 if (sensitiveVisible && isUpdating) { @@ -536,6 +566,17 @@ function BalanceCell({ channel }: { channel: Channel }) { }} isRefreshing={isUpdating} /> + {rawBalanceResponse !== null && ( + { + if (!open) { + setRawBalanceResponse(null) + } + }} + /> + )} ) } diff --git a/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx b/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx index 614de2cc..1941b3c7 100644 --- a/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx +++ b/web/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx @@ -22,21 +22,32 @@ import { ArrowRight, ArrowUp, Check, + ChevronDown, + ChevronRight, + CircleDollarSign, + Code2, Info, + ListTree, Plus, Shuffle, Trash2, type LucideIcon, } from 'lucide-react' -import { type ReactNode, useMemo, useRef, useState } from 'react' +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { ConfirmDialog } from '@/components/confirm-dialog' import { Dialog } from '@/components/dialog' import { JsonCodeEditor } from '@/components/json-code-editor' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' import { Input } from '@/components/ui/input' import { Popover, @@ -55,6 +66,7 @@ import { SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, @@ -64,6 +76,8 @@ import { import { cn } from '@/lib/utils' import { + ADVANCED_CUSTOM_BALANCE_LABEL, + ADVANCED_CUSTOM_BALANCE_PATH, ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, ADVANCED_CUSTOM_CONVERTER_OPTIONS, ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS, @@ -73,22 +87,27 @@ import { type AdvancedCustomAuthMode, buildAdvancedCustomAuth, createAdvancedCustomConfig, + createAdvancedCustomManagementRoute, createAdvancedCustomRoute, getAdvancedCustomAuthMode, getAdvancedCustomConverterDefaults, getAdvancedCustomConverterOptions, getAdvancedCustomIncomingPathLabel, getAdvancedCustomModelRuleKind, + getAdvancedCustomManagementRoute, getAdvancedCustomRegexModelPattern, getAdvancedCustomTemplateConfig, getAdvancedCustomUpstreamPathPlaceholder, getDefaultAdvancedCustomIncomingPath, isAdvancedCustomIncomingPathAllowed, + isAdvancedCustomManagementPath, normalizeAdvancedCustomConfig, parseAdvancedCustomRouteModels, parseAdvancedCustomConfig, stringifyAdvancedCustomConfig, validateAdvancedCustomConfig, + replaceAdvancedCustomForwardingRoutes, + replaceAdvancedCustomManagementRoute, } from '../../lib/advanced-custom' import type { AdvancedCustomAuthType, @@ -104,18 +123,24 @@ type AdvancedCustomEditorDialogProps = { onSave: (value: string) => void } -type AdvancedCustomEditMode = 'visual' | 'json' +type AdvancedCustomEditorTab = 'forwarding' | 'models' | 'balance' | 'json' const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]' const longSelectItemClass = 'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal' const routeEditorGridClassName = - 'lg:grid-cols-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]' + 'lg:grid-cols-[minmax(9rem,0.9fr)_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1.1fr)_minmax(0,0.85fr)_7rem]' const upstreamPathDescriptionKey = 'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.' const catchAllOrderErrorMessage = 'Catch-all route must be last for the same incoming path' const emptyAdvancedRoutes: AdvancedCustomRoute[] = [] +const advancedCustomTabs = new Set([ + 'forwarding', + 'models', + 'balance', + 'json', +]) type AdvancedCustomRouteRow = { route: AdvancedCustomRoute @@ -143,6 +168,52 @@ function isCatchAllRoute(route: AdvancedCustomRoute): boolean { return !route.models || route.models.length === 0 } +function getRouteConverterLabel(route: AdvancedCustomRoute): string { + const converter = route.converter || 'none' + return ( + ADVANCED_CUSTOM_CONVERTER_OPTIONS.find( + (option) => option.value === converter + )?.triggerLabel || converter + ) +} + +function getRouteConverters( + routes: AdvancedCustomRoute[] +): Array<{ converter: AdvancedCustomConverter; label: string }> { + const converters = new Map< + AdvancedCustomConverter, + { converter: AdvancedCustomConverter; label: string } + >() + for (const route of routes) { + const converter = route.converter || 'none' + if (!converters.has(converter)) { + converters.set(converter, { + converter, + label: getRouteConverterLabel(route), + }) + } + } + return [...converters.values()] +} + +export function RouteModeBadges(props: { routes: AdvancedCustomRoute[] }) { + const { t } = useTranslation() + return getRouteConverters(props.routes).map((item) => ( + + {item.converter === 'none' ? ( + + )) +} + function buildRouteGroups( routeRows: AdvancedCustomRouteRow[] ): AdvancedCustomRouteGroup[] { @@ -182,7 +253,8 @@ export function AdvancedCustomEditorDialog({ (_, routeIndex) => `advanced-custom-route-initial-${routeIndex}` ) }) - const [editMode, setEditMode] = useState('visual') + const [activeTab, setActiveTab] = + useState('forwarding') const [jsonText, setJsonText] = useState(() => stringifyAdvancedCustomConfig( parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() @@ -192,9 +264,9 @@ export function AdvancedCustomEditorDialog({ const [templateKey, setTemplateKey] = useState( ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || '' ) - const templateLabel = useMemo( - () => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey), - [templateKey] + const [templateConfirmOpen, setTemplateConfirmOpen] = useState(false) + const [expandedRouteGroups, setExpandedRouteGroups] = useState>( + () => new Set() ) const normalizedConfig = useMemo( @@ -202,7 +274,7 @@ export function AdvancedCustomEditorDialog({ [config] ) const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes - const routeRows = useMemo( + const allRouteRows = useMemo( () => routes.map((route, index) => ({ route, @@ -216,6 +288,14 @@ export function AdvancedCustomEditorDialog({ })), [routeKeys, routes] ) + const routeRows = useMemo( + () => + allRouteRows.filter( + (routeRow) => + !isAdvancedCustomManagementPath(getRouteIncomingPath(routeRow.route)) + ), + [allRouteRows] + ) const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows]) const usedIncomingPaths = useMemo( () => new Set(routeGroups.map((routeGroup) => routeGroup.incomingPath)), @@ -224,7 +304,9 @@ export function AdvancedCustomEditorDialog({ const availableIncomingPathOptions = useMemo( () => ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter( - (option) => !usedIncomingPaths.has(option.value) + (option) => + !isAdvancedCustomManagementPath(option.value) && + !usedIncomingPaths.has(option.value) ), [usedIncomingPaths] ) @@ -234,6 +316,43 @@ export function AdvancedCustomEditorDialog({ ) const canFixCatchAllOrder = validationError?.message === catchAllOrderErrorMessage + const modelListRoute = getAdvancedCustomManagementRoute( + normalizedConfig, + ADVANCED_CUSTOM_MODEL_LIST_PATH + ) + const balanceRoute = getAdvancedCustomManagementRoute( + normalizedConfig, + ADVANCED_CUSTOM_BALANCE_PATH + ) + const selectedTemplate = useMemo( + () => + ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find( + (template) => template.value === templateKey + ) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0], + [templateKey] + ) + + // Synchronize the draft only when the dialog opens or the source value changes. + // Route keys are disposable UI identity and do not belong in the saved config. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!open) return + const parsed = + parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() + const normalized = normalizeAdvancedCustomConfig(parsed) + setConfig(normalized) + setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0)) + setJsonText(stringifyAdvancedCustomConfig(normalized)) + setJsonError('') + setActiveTab('forwarding') + setTemplateConfirmOpen(false) + const firstForwardingPath = (normalized.advanced_routes || []) + .map((route) => getRouteIncomingPath(route)) + .find((path) => !isAdvancedCustomManagementPath(path)) + setExpandedRouteGroups( + firstForwardingPath ? new Set([firstForwardingPath]) : new Set() + ) + }, [open, value]) const createRouteKey = () => { routeKeyCounterRef.current += 1 @@ -254,7 +373,7 @@ export function AdvancedCustomEditorDialog({ const replaceRoutes = ( nextRoutes: AdvancedCustomRoute[], - nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) + nextRouteKeys = allRouteRows.map((routeRow) => routeRow.routeKey) ) => { setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) @@ -280,6 +399,7 @@ export function AdvancedCustomEditorDialog({ } }) setRouteKeys((current) => [...current, createRouteKey()]) + setExpandedRouteGroups((current) => new Set(current).add(incomingPath)) } const addRouteForIncomingPath = (incomingPath: string) => { @@ -299,6 +419,9 @@ export function AdvancedCustomEditorDialog({ } }) setRouteKeys((current) => [...current, createRouteKey()]) + setExpandedRouteGroups((current) => + new Set(current).add(resolvedIncomingPath) + ) } const removeRoute = (index: number) => { @@ -348,12 +471,18 @@ export function AdvancedCustomEditorDialog({ } }) replaceRoutes(nextRoutes) + setExpandedRouteGroups((current) => { + const next = new Set(current) + next.delete(group.incomingPath) + next.add(resolvedIncomingPath) + return next + }) } const swapRoutes = (fromIndex: number, toIndex: number) => { if (fromIndex === toIndex) return const nextRoutes = [...routes] - const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) + const nextRouteKeys = allRouteRows.map((routeRow) => routeRow.routeKey) const fromRoute = nextRoutes[fromIndex] nextRoutes[fromIndex] = nextRoutes[toIndex] nextRoutes[toIndex] = fromRoute @@ -387,7 +516,7 @@ export function AdvancedCustomEditorDialog({ if (lastSamePathIndex < 0 || index === lastSamePathIndex) return const nextRoutes = [...routes] - const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) + const nextRouteKeys = allRouteRows.map((routeRow) => routeRow.routeKey) const [route] = nextRoutes.splice(index, 1) const [routeKey] = nextRouteKeys.splice(index, 1) nextRoutes.splice(lastSamePathIndex, 0, route) @@ -418,9 +547,19 @@ export function AdvancedCustomEditorDialog({ const orderedRows = orderedRowsByPath.get(incomingPath) return orderedRows?.shift() || routeRow }) + const orderedRoutes = [...nextRows] + const orderedRouteKeys = [...nextRows] replaceRoutes( - nextRows.map((routeRow) => routeRow.route), - nextRows.map((routeRow) => routeRow.routeKey) + routes.map((route) => + isAdvancedCustomManagementPath(getRouteIncomingPath(route)) + ? route + : orderedRoutes.shift()?.route || route + ), + allRouteRows.map((routeRow) => + isAdvancedCustomManagementPath(getRouteIncomingPath(routeRow.route)) + ? routeRow.routeKey + : orderedRouteKeys.shift()?.routeKey || routeRow.routeKey + ) ) } @@ -441,19 +580,23 @@ export function AdvancedCustomEditorDialog({ return parsed } - const switchToVisualMode = () => { + const switchTab = (nextTab: AdvancedCustomEditorTab) => { + if (!advancedCustomTabs.has(nextTab)) return + if (activeTab !== 'json') { + if (nextTab === 'json') { + setJsonText(stringifyAdvancedCustomConfig(normalizedConfig)) + setJsonError('') + } + setActiveTab(nextTab) + return + } + const parsed = parseJsonEditorConfig() if (!parsed) return const normalized = normalizeAdvancedCustomConfig(parsed) setConfig(normalized) setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0)) - setEditMode('visual') - } - - const switchToJsonMode = () => { - setJsonText(stringifyAdvancedCustomConfig(normalizedConfig)) - setJsonError('') - setEditMode('json') + setActiveTab(nextTab) } const handleJsonChange = (nextValue: string) => { @@ -461,33 +604,39 @@ export function AdvancedCustomEditorDialog({ if (jsonError) setJsonError('') } - const applyTemplate = (mode: 'fill' | 'append') => { - const templateConfig = getAdvancedCustomTemplateConfig(templateKey) - let nextConfig = templateConfig + const selectTemplate = (nextTemplateKey: string) => { + const template = + ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find( + (option) => option.value === nextTemplateKey + ) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0] + setTemplateKey(template.value) + setTemplateConfirmOpen(true) + } - if (mode === 'append') { - const baseConfig = - editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig - if (!baseConfig) return - const base = normalizeAdvancedCustomConfig(baseConfig) - const template = normalizeAdvancedCustomConfig(templateConfig) - nextConfig = { - advanced_routes: [ - ...(base.advanced_routes || []), - ...(template.advanced_routes || []), - ], - } - } - - const normalized = normalizeAdvancedCustomConfig(nextConfig) + const applySelectedTemplate = () => { + if (!selectedTemplate) return + const templateRoutes = (selectedTemplate.config.advanced_routes || []).map( + (route) => ({ + ...route, + models: [], + }) + ) + const normalized = replaceAdvancedCustomForwardingRoutes( + normalizedConfig, + templateRoutes + ) setConfig(normalized) setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0)) setJsonText(stringifyAdvancedCustomConfig(normalized)) setJsonError('') + setExpandedRouteGroups( + new Set(templateRoutes.map((route) => getRouteIncomingPath(route))) + ) + setTemplateConfirmOpen(false) } const saveConfig = () => { - if (editMode === 'json') { + if (activeTab === 'json') { const parsed = parseJsonEditorConfig() if (!parsed) { toast.error(t('Please fix JSON errors before saving')) @@ -532,126 +681,125 @@ export function AdvancedCustomEditorDialog({ } > -
-
- - {t('Mode')} - - - - -
- - - {t('Template')} - - - - + switchTab(value as AdvancedCustomEditorTab)} + className='min-w-0 gap-0' + > +
+ + + + + + + + + +
-
- {editMode === 'visual' ? ( -
-
- { + if (typeof incomingPath === 'string') addRoute(incomingPath) + }} + > + + + + + + + {availableIncomingPathOptions.map((option) => ( + +
+ {option.label} + + {option.value} + +
+
+ ))} +
+
+ + + +
{validationError ? ( @@ -677,40 +825,127 @@ export function AdvancedCustomEditorDialog({ ) : null} -

- {t(upstreamPathDescriptionKey)} -

- -
+
{routeGroups.map((routeGroup) => ( - - addRouteForIncomingPath(routeGroup.incomingPath) + open={expandedRouteGroups.has(routeGroup.incomingPath)} + onOpenChange={(expanded) => + setExpandedRouteGroups((current) => { + const next = new Set(current) + if (expanded) next.add(routeGroup.incomingPath) + else next.delete(routeGroup.incomingPath) + return next + }) } - onIncomingPathChange={(nextIncomingPath) => - updateGroupIncomingPath(routeGroup, nextIncomingPath) - } - onMoveRoute={(index, direction) => - moveRouteWithinGroup(index, direction) - } - onMoveRouteToEnd={moveRouteToGroupEnd} - onRemoveRoute={removeRoute} - onRouteChange={updateRoute} - /> + className='rounded-md border' + > + + } + > + {expandedRouteGroups.has(routeGroup.incomingPath) ? ( + + + + addRouteForIncomingPath(routeGroup.incomingPath) + } + onIncomingPathChange={(nextIncomingPath) => + updateGroupIncomingPath(routeGroup, nextIncomingPath) + } + onMoveRoute={(index, direction) => + moveRouteWithinGroup(index, direction) + } + onMoveRouteToEnd={moveRouteToGroupEnd} + onRemoveRoute={removeRoute} + onRouteChange={updateRoute} + /> + + ))}
-
- ) : ( -
-
- - {t('Advanced text editing')} - -
+ + + + + setConfig((current) => + replaceAdvancedCustomManagementRoute( + current, + ADVANCED_CUSTOM_MODEL_LIST_PATH, + route + ) + ) + } + /> + + + + + setConfig((current) => + replaceAdvancedCustomManagementRoute( + current, + ADVANCED_CUSTOM_BALANCE_PATH, + route + ) + ) + } + /> + + + {jsonError}

) : null} -
- )} + + + + ) } +function ManagementRouteEditor({ + route, + path, + title, + description, + onChange, +}: { + route: AdvancedCustomRoute | undefined + path: string + title: string + description: string + onChange: (route: AdvancedCustomRoute | null) => void +}) { + const { t } = useTranslation() + const authMode = route ? getAdvancedCustomAuthMode(route) : 'default' + + if (!route) { + return ( +
+
+

{title}

+

+ {description} +

+

{path}

+
+ +
+ ) + } + + const updateAuth = ( + field: Exclude, 'type'>, + value: string + ) => { + if (!route.auth || route.auth.type === 'none') return + onChange({ + ...route, + auth: { + type: route.auth.type, + name: route.auth.name || '', + value: route.auth.value || '', + [field]: value, + }, + }) + } + + return ( +
+
+
+

{title}

+

{description}

+

{path}

+
+ +
+
+ + + onChange({ ...route, upstream_path: event.target.value }) + } + placeholder={ + path === ADVANCED_CUSTOM_MODEL_LIST_PATH + ? '/v1/models' + : '/dashboard/billing/credit_grants' + } + /> + + + + + {authMode === 'header' || authMode === 'query' ? ( + <> + + updateAuth('name', event.target.value)} + placeholder={ + authMode === 'header' ? 'Authorization' : 'api_key' + } + /> + + + updateAuth('value', event.target.value)} + placeholder={ + authMode === 'header' ? 'Bearer {api_key}' : '{api_key}' + } + /> + + + ) : null} +
+

+ {t(upstreamPathDescriptionKey)} +

+
+ ) +} + function RouteGroupEditor({ group, usedIncomingPaths, validationError, + hideHeader = false, onAddRoute, onIncomingPathChange, onMoveRoute, @@ -747,6 +1143,7 @@ function RouteGroupEditor({ group: AdvancedCustomRouteGroup usedIncomingPaths: ReadonlySet validationError: ReturnType + hideHeader?: boolean onAddRoute: () => void onIncomingPathChange: (incomingPath: string | null) => void onMoveRoute: (index: number, direction: -1 | 1) => void @@ -782,80 +1179,84 @@ function RouteGroupEditor({ groupHasError && 'border-destructive/60' )} > -
-
-
- {t('Route group')} - - {group.routeRows.length} {t('Routes')} - - {isModelListGroup ? ( - - {ADVANCED_CUSTOM_MODEL_LIST_LABEL} + {!hideHeader ? ( +
+
+
+ {t('Route group')} + + {group.routeRows.length} {t('Routes')} - ) : ( - - {hasCatchAll ? t('Fallback route') : t('Model-scoped only')} - - )} - {!isModelListGroup && !catchAllIsLast ? ( - {t('Fallback must be last')} - ) : null} -
- - - {ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ( - 1) - } - className={longSelectItemClass} - > -
- {option.label} - - {option.value} - -
-
- ))} -
- - + + + {incomingPathLabel} + + + + + {ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => ( + 1) + } + className={longSelectItemClass} + > +
+ {option.label} + + {option.value} + +
+
+ ))} +
+
+ +
+ + {!isModelListGroup ? ( + + ) : null}
+ ) : null} - {!isModelListGroup ? ( - - ) : null} -
- -
+

{isModelListGroup ? t( @@ -960,17 +1361,11 @@ function RouteEditor({ () => getAdvancedCustomConverterOptions(incomingPath), [incomingPath] ) - const converterLabel = getOptionLabel( - ADVANCED_CUSTOM_CONVERTER_OPTIONS, - converter - ) const converterTriggerLabel = ADVANCED_CUSTOM_CONVERTER_OPTIONS.find( (option) => option.value === converter - )?.triggerLabel || converterLabel + )?.triggerLabel || converter const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode) - const isNativeConverter = converter === 'none' - const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle const modelsInputValue = route.models?.join(', ') || '' const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue) const isFallback = !isModelListRoute && parsedRouteModels.length === 0 @@ -1036,8 +1431,8 @@ function RouteEditor({ )} >

-
-
+
+
{t('Route')} {index + 1}
@@ -1049,31 +1444,7 @@ function RouteEditor({ {!isModelListRoute && isFallback ? ( {t('Fallback')} ) : null} - - - - } - > - - - {t(converterLabel)} - - - +
diff --git a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx index a9f6d11e..d5f60fe2 100644 --- a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -22,7 +22,12 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + CodeBlock, + CodeBlockCopyButton, +} from '@/components/ai-elements/code-block' import { Dialog } from '@/components/dialog' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { IconBadge } from '@/components/ui/icon-badge' import { formatCurrencyFromUSD } from '@/lib/currency' @@ -37,14 +42,12 @@ import { } from './codex-usage-dialog' type BalanceQueryDialogProps = { + initialRawResponse?: string open: boolean onOpenChange: (open: boolean) => void } -export function BalanceQueryDialog({ - open, - onOpenChange, -}: BalanceQueryDialogProps) { +export function BalanceQueryDialog(props: BalanceQueryDialogProps) { const { t } = useTranslation() const { currentRow, setCurrentRow } = useChannels() const queryClient = useQueryClient() @@ -53,6 +56,9 @@ export function BalanceQueryDialog({ const [balanceUpdatedTime, setBalanceUpdatedTime] = useState( null ) + const [rawResponse, setRawResponse] = useState( + props.initialRawResponse ?? null + ) const [codexUsageResponse, setCodexUsageResponse] = useState(null) @@ -79,10 +85,10 @@ export function BalanceQueryDialog({ useEffect(() => { if (!isCodex) return - if (!open) return + if (!props.open) return handleQueryCodexUsage() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, isCodex]) + }, [props.open, isCodex]) if (!currentRow) return null @@ -109,6 +115,9 @@ export function BalanceQueryDialog({ await queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists(), }) + setRawResponse(null) + } else if (response.success && response.raw_response !== undefined) { + setRawResponse(response.raw_response) } else { toast.error(response.message || t('Failed to query balance')) } @@ -124,8 +133,9 @@ export function BalanceQueryDialog({ const handleClose = () => { setBalance(null) setBalanceUpdatedTime(null) + setRawResponse(null) setCodexUsageResponse(null) - onOpenChange(false) + props.onOpenChange(false) } const formatBalance = (bal: number) => @@ -143,7 +153,7 @@ export function BalanceQueryDialog({ if (isCodex) { return ( { if (!v) handleClose() }} @@ -158,7 +168,7 @@ export function BalanceQueryDialog({ return (
- {/* Current Balance Display */} -
-
- - - - {t('Current Balance')} -
-
- {balance !== null - ? formatBalance(balance) - : formatBalance(currentRow.balance)} -
-
- {t('Last updated:')}{' '} - {formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)} -
-
+ {rawResponse !== null ? ( + <> + + {t('Balance response not recognized')} + + {t( + 'The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.' + )} + + + + + + + ) : ( + <> + {/* Current Balance Display */} +
+
+ + + + {t('Current Balance')} +
+
+ {balance !== null + ? formatBalance(balance) + : formatBalance(currentRow.balance)} +
+
+ {t('Last updated:')}{' '} + {formatDate( + balanceUpdatedTime ?? currentRow.balance_updated_time + )} +
+
+ + )} {/* Balance Update Button */}