diff --git a/backend/internal/handler/admin/account_upstream_billing_rates.go b/backend/internal/handler/admin/account_upstream_billing_rates.go index 5c3b322b70ec..3d42e2278183 100644 --- a/backend/internal/handler/admin/account_upstream_billing_rates.go +++ b/backend/internal/handler/admin/account_upstream_billing_rates.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -28,6 +29,10 @@ type upstreamBillingRatesResponse struct { // current account-list page. It never probes an upstream and never loads // today-stats, usage, concurrency, or credentials for the response. // +// It must resolve the same page as List for the same query: the account page +// applies the snapshots only when the total and the ID order equal the list's, +// and reloads the whole list otherwise. +// // GET /api/v1/admin/accounts/upstream-billing-rates func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) { if h.adminService == nil { @@ -47,24 +52,48 @@ func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) { sortBy := c.DefaultQuery("sort_by", "name") sortOrder := c.DefaultQuery("sort_order", "asc") + // group_id is the name List reads; the older group applies only when + // group_id is absent. + groupQuery, hasGroupID := c.GetQuery("group_id") + if !hasGroupID { + groupQuery = c.Query("group") + } var groupID int64 - if groupQuery := c.Query("group"); groupQuery != "" { + if groupQuery != "" { if groupQuery == accountListGroupUngroupedQueryValue { groupID = service.AccountListGroupUngrouped } else { parsed, err := strconv.ParseInt(groupQuery, 10, 64) if err != nil || parsed < 0 { - response.BadRequest(c, "invalid group filter") + response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")) return } groupID = parsed } } - accounts, total, err := h.adminService.ListAccounts( - c.Request.Context(), page, pageSize, platform, accountType, status, - search, groupID, privacyMode, sortBy, sortOrder, + var ( + accounts []service.Account + total int64 + err error ) + if hasAccountConsoleFilters(c) { + filters, filterErr := parseAccountConsoleFilters(c, groupID) + if filterErr != nil { + response.ErrorFrom(c, filterErr) + return + } + var consoleService accountConsoleAdminService + consoleService, err = h.accountConsoleService() + if err == nil { + accounts, total, err = consoleService.ListAccountsConsole(c.Request.Context(), page, pageSize, filters) + } + } else { + accounts, total, err = h.adminService.ListAccounts( + c.Request.Context(), page, pageSize, platform, accountType, status, + search, groupID, privacyMode, sortBy, sortOrder, + ) + } if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/account_upstream_billing_rates_test.go b/backend/internal/handler/admin/account_upstream_billing_rates_test.go new file mode 100644 index 000000000000..8b8a1b802385 --- /dev/null +++ b/backend/internal/handler/admin/account_upstream_billing_rates_test.go @@ -0,0 +1,220 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const ( + accountListRoute = "/api/v1/admin/accounts" + upstreamBillingRatesRoute = "/api/v1/admin/accounts/upstream-billing-rates" +) + +// accountPageCall is one list call as the admin service received it: the scalar arguments of ListAccounts, or the +// filters of ListAccountsConsole when console is set. +type accountPageCall struct { + page, pageSize int + platform, accountType, status, search string + groupID int64 + privacyMode, sortBy, sortOrder string + console *service.AccountConsoleFilters +} + +// group is the group the call filtered by, whichever list method received it. +func (c accountPageCall) group() int64 { + if c.console != nil { + return c.console.GroupID + } + return c.groupID +} + +// billingRatesAdmin records the account page each request resolves and answers every list call with rows and total. +type billingRatesAdmin struct { + *accountTaxonomyHandlerStub + calls []accountPageCall + rows []service.Account + total int64 + listErr error +} + +func (s *billingRatesAdmin) ListAccounts(_ context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]service.Account, int64, error) { + s.calls = append(s.calls, accountPageCall{ + page: page, pageSize: pageSize, platform: platform, accountType: accountType, status: status, search: search, + groupID: groupID, privacyMode: privacyMode, sortBy: sortBy, sortOrder: sortOrder, + }) + return s.rows, s.total, s.listErr +} + +func (s *billingRatesAdmin) ListAccountsConsole(_ context.Context, page, pageSize int, filters service.AccountConsoleFilters) ([]service.Account, int64, error) { + s.calls = append(s.calls, accountPageCall{page: page, pageSize: pageSize, console: &filters}) + return s.rows, s.total, s.listErr +} + +func setupUpstreamBillingRatesRouter() (*gin.Engine, *billingRatesAdmin) { + gin.SetMode(gin.TestMode) + router := gin.New() + admin := &billingRatesAdmin{accountTaxonomyHandlerStub: newAccountTaxonomyHandlerStub()} + handler := NewAccountHandler(admin, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.GET(accountListRoute, handler.List) + router.GET(upstreamBillingRatesRoute, handler.GetUpstreamBillingRates) + return router, admin +} + +// requestAccountPage sends the query to the route and returns the response with the list calls the request made. +func requestAccountPage(router *gin.Engine, admin *billingRatesAdmin, route, query string) (*httptest.ResponseRecorder, []accountPageCall) { + admin.calls = nil + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, route+"?"+query, nil)) + return recorder, admin.calls +} + +func TestAccountHandlerGetUpstreamBillingRatesResolvesSamePageAsList(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + digestTerm := "sha256:" + strings.Repeat("ab", 32) + over100Bytes := strings.Repeat("k", 120) + for _, test := range []struct { + name string + query string + console bool + }{ + {name: "no_filter"}, + {name: "search", query: "search=" + digestTerm}, + {name: "search_over_100_bytes", query: "search=" + over100Bytes}, + {name: "group", query: "group_id=12"}, + {name: "ungrouped", query: "group_id=ungrouped"}, + {name: "privacy_and_billing_rate_sort", query: "privacy_mode=training_off&sort_by=upstream_billing_rate&sort_order=desc"}, + {name: "scalar_filters", query: "platform=openai&type=apikey&status=active"}, + {name: "page", query: "page=3&page_size=50"}, + {name: "statuses", query: "statuses=active,error", console: true}, + {name: "console_ungrouped", query: "plans=plus&group_id=ungrouped", console: true}, + {name: "console_search_over_100_bytes", query: "tags=3&search=" + over100Bytes, console: true}, + {name: "console_page", query: "page=2&page_size=5&account_ids=11,12", console: true}, + { + name: "full_console", + query: "platforms=openai,anthropic&types=apikey,oauth&statuses=active,error&plans=plus,team" + + "&proxies=direct,4&folder=uncategorized,7&tags=3,9&account_ids=11,12,13&group_id=12" + + "&privacy_mode=training_off&search=" + digestTerm + "&sort_by=upstream_billing_rate&sort_order=desc", + console: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + listResponse, listed := requestAccountPage(router, admin, accountListRoute, test.query) + ratesResponse, rated := requestAccountPage(router, admin, upstreamBillingRatesRoute, test.query) + + require.Equal(t, http.StatusOK, listResponse.Code) + require.Equal(t, http.StatusOK, ratesResponse.Code) + require.Len(t, listed, 1) + require.Equal(t, test.console, listed[0].console != nil, "the query must take the list path the case is about") + require.Equal(t, listed, rated) + }) + } +} + +func TestAccountHandlerGetUpstreamBillingRatesFailsLikeList(t *testing.T) { + listErr := errors.New("synthetic list outage") + for _, test := range []struct { + name string + query string + listErr error + wantStatus int + wantCalls int + }{ + {name: "invalid_group", query: "group_id=abc", wantStatus: http.StatusBadRequest}, + {name: "negative_group", query: "group_id=-3", wantStatus: http.StatusBadRequest}, + {name: "console_invalid_group", query: "statuses=active&group_id=abc", wantStatus: http.StatusBadRequest}, + {name: "console_invalid_filter", query: "tags=abc", wantStatus: http.StatusBadRequest}, + {name: "list_error", listErr: listErr, wantStatus: http.StatusInternalServerError, wantCalls: 1}, + {name: "console_list_error", query: "statuses=active", listErr: listErr, wantStatus: http.StatusInternalServerError, wantCalls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + admin.listErr = test.listErr + listResponse, _ := requestAccountPage(router, admin, accountListRoute, test.query) + ratesResponse, rated := requestAccountPage(router, admin, upstreamBillingRatesRoute, test.query) + + require.Equal(t, test.wantStatus, listResponse.Code) + require.Equal(t, listResponse.Code, ratesResponse.Code) + require.JSONEq(t, listResponse.Body.String(), ratesResponse.Body.String()) + require.Len(t, rated, test.wantCalls) + }) + } +} + +func TestAccountHandlerGetUpstreamBillingRatesFallsBackToOlderGroupName(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + for _, test := range []struct { + query string + wantGroup int64 + }{ + {query: "group=12", wantGroup: 12}, + {query: "group=12&statuses=active", wantGroup: 12}, + {query: "group=12&group_id=73", wantGroup: 73}, + {query: "group=12&group_id=73&statuses=active", wantGroup: 73}, + // An empty group_id is still the name List reads, and means no group filter there. + {query: "group=12&group_id=", wantGroup: 0}, + } { + t.Run(test.query, func(t *testing.T) { + recorder, calls := requestAccountPage(router, admin, upstreamBillingRatesRoute, test.query) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, calls, 1) + require.Equal(t, test.wantGroup, calls[0].group()) + }) + } +} + +func TestAccountHandlerGetUpstreamBillingRatesKeepsResponseShapeWithConsoleFilters(t *testing.T) { + router, admin := setupUpstreamBillingRatesRouter() + admin.rows = []service.Account{ + {ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Extra: map[string]any{ + service.UpstreamBillingProbeExtraKey: map[string]any{ + "status": service.UpstreamBillingProbeStatusOK, "data": map[string]any{"effective_rate_multiplier": 0.045}, + "last_attempt_at": "2026-08-01T00:00:00Z", "next_probe_at": "2026-08-01T00:30:00Z", + }, + }}, + {ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}, + {ID: 19, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth}, + } + admin.total = 57 + const query = "page=2&page_size=3&statuses=active&tags=3&sort_by=upstream_billing_rate&sort_order=desc" + + recorder, calls := requestAccountPage(router, admin, upstreamBillingRatesRoute, query) + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, calls, 1) + require.NotNil(t, calls[0].console) + + var body struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.JSONEq(t, `{ + "items": [ + {"account_id": 42, "snapshot": { + "status": "ok", "data": {"effective_rate_multiplier": 0.045}, + "last_attempt_at": "2026-08-01T00:00:00Z", "next_probe_at": "2026-08-01T00:30:00Z" + }}, + {"account_id": 7, "snapshot": null}, + {"account_id": 19, "snapshot": null} + ], + "total": 57, "page": 2, "page_size": 3 + }`, string(body.Data)) + require.Equal(t, "private, no-cache", recorder.Header().Get("Cache-Control")) + etag := recorder.Header().Get("ETag") + require.NotEmpty(t, etag) + + request := httptest.NewRequest(http.MethodGet, upstreamBillingRatesRoute+"?"+query, nil) + request.Header.Set("If-None-Match", etag) + notModified := httptest.NewRecorder() + router.ServeHTTP(notModified, request) + require.Equal(t, http.StatusNotModified, notModified.Code) + require.Empty(t, notModified.Body.String()) +} diff --git a/backend/internal/repository/account_taxonomy_integration_test.go b/backend/internal/repository/account_taxonomy_integration_test.go index f19edc74bddf..decd95b64fda 100644 --- a/backend/internal/repository/account_taxonomy_integration_test.go +++ b/backend/internal/repository/account_taxonomy_integration_test.go @@ -417,6 +417,34 @@ func TestAccountTaxonomyConsoleSearchByAPIKeyDigestIntegration(t *testing.T) { }) } +func TestAccountTaxonomyConsoleOrderBreaksTiesByIDIntegration(t *testing.T) { + ctx := context.Background() + prefix := taxonomyIntegrationPrefix("tie-order") + client, admin := newAccountTaxonomyIntegrationAdmin(t, prefix) + + // Five accounts that are equal in the sorted column. + ids := make([]int64, 0, 5) + for _, name := range []string{"c", "a", "e", "b", "d"} { + ids = append(ids, mustCreateAccount(t, client, &service.Account{Name: prefix + name, Priority: 7}).ID) + } + list := func(order string, page, pageSize int) []int64 { + accounts, total, err := admin.ListAccountsConsole(ctx, page, pageSize, service.AccountConsoleFilters{ + Search: prefix, SortBy: "priority", SortOrder: order, + }) + require.NoError(t, err) + require.Equal(t, int64(len(ids)), total) + return idsOfAccounts(accounts) + } + + descending := []int64{ids[4], ids[3], ids[2], ids[1], ids[0]} + require.Equal(t, ids, list("asc", 1, 20)) + require.Equal(t, descending, list("desc", 1, 20)) + // Pages of one ordering neither overlap nor skip. + require.Equal(t, descending[:2], list("desc", 1, 2)) + require.Equal(t, descending[2:4], list("desc", 2, 2)) + require.Equal(t, descending[4:], list("desc", 3, 2)) +} + func TestAccountTaxonomyBulkUpdateAndOrderingIntegration(t *testing.T) { ctx := context.Background() prefix := taxonomyIntegrationPrefix("bulk") diff --git a/backend/internal/service/admin_account_taxonomy.go b/backend/internal/service/admin_account_taxonomy.go index 19494e79ec7f..522dd253832f 100644 --- a/backend/internal/service/admin_account_taxonomy.go +++ b/backend/internal/service/admin_account_taxonomy.go @@ -753,10 +753,12 @@ func (s *adminServiceImpl) accountConsoleQuery(filters AccountConsoleFilters) *d if field == "" { field = dbaccount.FieldName } + // The account ID breaks ties, as in the plain list's ordering, so one query always yields one order: pages do + // not overlap, and the upstream billing rates request sees the page the list request saw. if strings.EqualFold(filters.SortOrder, "desc") { - query = query.Order(dbent.Desc(field)) + query = query.Order(dbent.Desc(field), dbent.Desc(dbaccount.FieldID)) } else { - query = query.Order(dbent.Asc(field)) + query = query.Order(dbent.Asc(field), dbent.Asc(dbaccount.FieldID)) } return query } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index f3edca31b891..df548b6e0e73 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -117,16 +117,7 @@ export interface AccountUpstreamBillingRatesWithEtagResult { export async function getUpstreamBillingRatesWithEtag( page: number = 1, pageSize: number = 20, - filters?: { - platform?: string - type?: string - status?: string - group?: string - search?: string - privacy_mode?: string - sort_by?: string - sort_order?: 'asc' | 'desc' - }, + filters?: AccountListFilters, options?: { signal?: AbortSignal etag?: string | null diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 232952988a71..b3965f5183be 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1802,19 +1802,8 @@ const reload = async () => { await refreshTodayStatsBatch() } -const buildUpstreamBillingRateFilters = () => { - const rawParams = toRaw(params) as Record - return { - platform: typeof rawParams.platform === 'string' ? rawParams.platform : '', - type: typeof rawParams.type === 'string' ? rawParams.type : '', - status: typeof rawParams.status === 'string' ? rawParams.status : '', - group: typeof rawParams.group === 'string' ? rawParams.group : '', - search: typeof rawParams.search === 'string' ? rawParams.search : '', - privacy_mode: typeof rawParams.privacy_mode === 'string' ? rawParams.privacy_mode : '', - sort_by: sortState.sort_by, - sort_order: sortState.sort_order - } -} +// The rates endpoint answers for the page the list request returns, so it is asked with that request's filters. +const buildUpstreamBillingRateFilters = () => buildConsoleAPIParams(true) const sameAccountIDOrder = (left: number[], right: number[]) => left.length === right.length && left.every((id, index) => id === right[index]) diff --git a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts index f90530122f23..c27ede44207c 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts @@ -14,6 +14,7 @@ const { getById, getAvailableModels, listWithEtag, + getUpstreamBillingRatesWithEtag, getFacets, listFolders, listTags, @@ -33,6 +34,7 @@ const { getById: vi.fn(), getAvailableModels: vi.fn(), listWithEtag: vi.fn(), + getUpstreamBillingRatesWithEtag: vi.fn(), getFacets: vi.fn(), listFolders: vi.fn(), listTags: vi.fn(), @@ -73,6 +75,7 @@ vi.mock('@/api/admin', () => ({ getAPIKeyVisibility: vi.fn().mockResolvedValue({ enabled: false }), getAvailableModels, listWithEtag, + getUpstreamBillingRatesWithEtag, getFacets, listFolders, listTags, @@ -295,6 +298,10 @@ const recordListSearches = () => { const accountRequestArguments = () => JSON.stringify(Object.values(adminAPI.accounts).map(request => vi.mocked(request).mock.calls)) +// the refresh of the upstream billing rates the page makes after a billing probe +const refreshBillingRates = (wrapper: ReturnType): Promise => + (wrapper.vm as any).refreshAccountsAfterUpstreamBillingProbe() + describe('admin AccountsView Cockpit console', () => { it('preserves the submitted bulk-edit selection in the original window after clearing page selection', async () => { const wrapper = mountView() @@ -322,6 +329,7 @@ describe('admin AccountsView Cockpit console', () => { getById.mockReset().mockResolvedValue(account) getAvailableModels.mockReset().mockResolvedValue(structuredClone(modelDisplayContract.expected)) listWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: null, data: null }) + getUpstreamBillingRatesWithEtag.mockReset().mockResolvedValue({ notModified: true, etag: null, data: null }) getFacets.mockReset().mockResolvedValue({ total: 1, uncategorized_count: 1, platforms: [], types: [], statuses: [], plans: [], proxies: [], folders: [], tags: [] }) listFolders.mockReset().mockResolvedValue([]) listTags.mockReset().mockResolvedValue([]) @@ -740,6 +748,97 @@ describe('admin AccountsView Cockpit console', () => { wrapper.unmount() }) + it('asks for upstream billing rates of the current page with the filters of the list request', async () => { + sessionStorage.setItem('account-console-sensitive-filters-v1', JSON.stringify({ search: 'console', account_ids: [] })) + listFolders.mockResolvedValue([{ id: 7, name: 'Production', sort_order: 0, account_count: 1 }]) + listTags.mockResolvedValue([{ id: 3, name: 'canary', sort_order: 0, account_count: 1 }]) + // useTableLoader hands every list request the same params object, so the filters of a request are copied as it is made + const listRequests: Record[] = [] + listAccounts.mockImplementation(async (_page: number, _pageSize: number, filters: Record) => { + listRequests.push({ ...filters }) + return { items: [account], total: 51, page: 2, page_size: 50, pages: 2 } + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/admin/accounts', component: { template: '
' } }] + }) + await router.push('/admin/accounts?folder=7&statuses=active&group_id=23&tags=3&sort_by=status&sort_order=desc&page=2&page_size=50') + await router.isReady() + const wrapper = mountView([router]) + try { + // the restored search text is looked up before the first list request + await vi.waitFor(() => expect(listRequests.length).toBeGreaterThan(0)) + await flushPromises() + + await refreshBillingRates(wrapper) + + expect(getUpstreamBillingRatesWithEtag).toHaveBeenCalledTimes(1) + const [page, pageSize, rateFilters] = getUpstreamBillingRatesWithEtag.mock.calls[0] + expect([page, pageSize]).toEqual([2, 50]) + expect(rateFilters).toEqual({ + folder: '7', statuses: 'active', group_id: '23', tags: '3', search: 'console', sort_by: 'status', sort_order: 'desc' + }) + for (const legacyKey of ['platform', 'type', 'status', 'group']) expect(rateFilters).not.toHaveProperty(legacyKey) + // the filters of the list request: all it sends but lite and include_scheduler_score, which only shape its rows + const { lite: _lite, include_scheduler_score: _includeSchedulerScore, ...listFilters } = listRequests[listRequests.length - 1] + expect(rateFilters).toEqual(listFilters) + } finally { + wrapper.unmount() + } + }) + + it('applies upstream billing rates answered for the listed accounts without requesting the list again', async () => { + const snapshot = { + status: 'ok', + data: { effective_rate_multiplier: 0.065 }, + synced_rate_multiplier: 0.065, + last_attempt_at: '2026-07-29T00:00:00Z', + next_probe_at: '2026-07-29T00:30:00Z' + } + getUpstreamBillingRatesWithEtag.mockResolvedValue({ + notModified: false, + etag: '"rates"', + data: { items: [{ account_id: account.id, snapshot }], total: 1, page: 1, page_size: 20 } + }) + const wrapper = mountView() + try { + await flushPromises() + listAccounts.mockClear() + + await refreshBillingRates(wrapper) + await flushPromises() + + expect(getUpstreamBillingRatesWithEtag).toHaveBeenCalledTimes(1) + expect(listAccounts).not.toHaveBeenCalled() + const [row] = (wrapper.vm as any).accounts + expect(row.extra.upstream_billing_probe).toEqual(snapshot) + expect(row.rate_multiplier).toBe(snapshot.synced_rate_multiplier) + } finally { + wrapper.unmount() + } + }) + + it('asks for upstream billing rates by digest term when the search box holds the API key of an account', async () => { + const key = 'sk-rated-upstream-key' + hasAPIKeyDigest.mockResolvedValue(true) + const wrapper = mountView() + try { + await flushPromises() + const searches = recordListSearches() + typeSearch(wrapper, key) + await vi.waitFor(() => expect(searches).toEqual([keyDigestTerm(key)]), SEARCH_RELOAD_WAIT) + await flushPromises() + + await refreshBillingRates(wrapper) + + expect(getUpstreamBillingRatesWithEtag).toHaveBeenCalledTimes(1) + expect(getUpstreamBillingRatesWithEtag.mock.calls[0][2].search).toBe(keyDigestTerm(key)) + expect(JSON.stringify(getUpstreamBillingRatesWithEtag.mock.calls)).not.toContain(key) + } finally { + wrapper.unmount() + } + }) + it('preserves the account list and performs no reads when account testing closes', async () => { const router = createRouter({ history: createMemoryHistory(),