diff --git a/backend/internal/handler/admin/account_upstream_billing_rates.go b/backend/internal/handler/admin/account_upstream_billing_rates.go new file mode 100644 index 0000000000..5c3b322b70 --- /dev/null +++ b/backend/internal/handler/admin/account_upstream_billing_rates.go @@ -0,0 +1,99 @@ +package admin + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// upstreamBillingRatesResponse is deliberately smaller than the regular +// account-list response. The frontend uses the returned ID order to detect a +// page boundary/order change and only applies snapshots when the page is +// otherwise stable. +type upstreamBillingRatesResponse struct { + Items []service.UpstreamBillingRateSnapshotItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// GetUpstreamBillingRates returns persisted upstream billing snapshots for the +// current account-list page. It never probes an upstream and never loads +// today-stats, usage, concurrency, or credentials for the response. +// +// GET /api/v1/admin/accounts/upstream-billing-rates +func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) { + if h.adminService == nil { + response.Error(c, http.StatusServiceUnavailable, "account service unavailable") + return + } + + page, pageSize := response.ParsePagination(c) + platform := c.Query("platform") + accountType := c.Query("type") + status := c.Query("status") + search := strings.TrimSpace(c.Query("search")) + if len(search) > 100 { + search = search[:100] + } + privacyMode := strings.TrimSpace(c.Query("privacy_mode")) + sortBy := c.DefaultQuery("sort_by", "name") + sortOrder := c.DefaultQuery("sort_order", "asc") + + var groupID int64 + if groupQuery := c.Query("group"); 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") + return + } + groupID = parsed + } + } + + 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 + } + + payload := upstreamBillingRatesResponse{ + Items: service.BuildUpstreamBillingRateSnapshotItems(accounts), + Total: total, + Page: page, + PageSize: pageSize, + } + c.Header("Cache-Control", "private, no-cache") + etag := buildUpstreamBillingRatesETag(payload) + if etag != "" { + c.Header("ETag", etag) + if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + return + } + } + + response.Success(c, payload) +} + +func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string { + raw, err := json.Marshal(payload) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return "\"" + hex.EncodeToString(sum[:]) + "\"" +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d618fb45e7..39418b0d31 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -358,6 +358,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts := admin.Group("/accounts") { accounts.GET("", h.Admin.Account.List) + accounts.GET("/upstream-billing-rates", h.Admin.Account.GetUpstreamBillingRates) accounts.GET("/upstream-billing-probe/settings", h.Admin.Account.GetUpstreamBillingProbeSettings) accounts.PUT("/upstream-billing-probe/settings", h.Admin.Account.UpdateUpstreamBillingProbeSettings) accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch) diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 7255464c03..5ab821cf2c 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -128,6 +128,35 @@ type UpstreamBillingProbeResult struct { Error string `json:"error,omitempty"` } +// UpstreamBillingRateSnapshotItem is the compact representation used by the +// account table's background refresh. It intentionally excludes credentials, +// runtime counters, and usage data from the response. +type UpstreamBillingRateSnapshotItem struct { + AccountID int64 `json:"account_id"` + Snapshot *UpstreamBillingProbeSnapshot `json:"snapshot"` +} + +// BuildUpstreamBillingRateSnapshotItems projects account rows into the +// read-only payload used by the rate refresh endpoint. Decode snapshots here +// so malformed or legacy extra data is handled consistently with probe logic. +func BuildUpstreamBillingRateSnapshotItems(accounts []Account) []UpstreamBillingRateSnapshotItem { + items := make([]UpstreamBillingRateSnapshotItem, 0, len(accounts)) + for _, account := range accounts { + var snapshot *UpstreamBillingProbeSnapshot + // The billing endpoint is supported by every API-key platform; limiting + // this projection to OpenAI would make the background refresh erase the + // other platforms' persisted snapshots from the table. + if account.Type == AccountTypeAPIKey { + snapshot = decodeUpstreamBillingProbeSnapshot(account.Extra) + } + items = append(items, UpstreamBillingRateSnapshotItem{ + AccountID: account.ID, + Snapshot: snapshot, + }) + } + return items +} + type upstreamBillingProbeResponse struct { Object string `json:"object"` SchemaVersion int `json:"schema_version"` diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 6690a80c15..0855c131a9 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -29,6 +29,45 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { require.False(t, isUpstreamBillingProbeAccount(nil)) } +func TestBuildUpstreamBillingRateSnapshotItemsPreservesAllAPIKeyPlatforms(t *testing.T) { + makeAccount := func(id int64, platform, accountType string, snapshot any) Account { + return Account{ + ID: id, + Platform: platform, + Type: accountType, + Extra: map[string]any{UpstreamBillingProbeExtraKey: snapshot}, + } + } + + accounts := []Account{ + makeAccount(1, PlatformOpenAI, AccountTypeAPIKey, map[string]any{ + "status": "ok", + "data": map[string]any{"effective_rate_multiplier": 0.045}, + }), + makeAccount(2, PlatformAnthropic, AccountTypeAPIKey, map[string]any{ + "status": "ok", + "data": map[string]any{"effective_rate_multiplier": 0.047}, + }), + makeAccount(3, PlatformGemini, AccountTypeAPIKey, map[string]any{ + "status": "failed", + }), + makeAccount(4, PlatformOpenAI, AccountTypeOAuth, map[string]any{ + "status": "ok", + }), + } + + items := BuildUpstreamBillingRateSnapshotItems(accounts) + require.Len(t, items, len(accounts)) + require.Equal(t, int64(1), items[0].AccountID) + require.NotNil(t, items[0].Snapshot) + require.NotNil(t, items[1].Snapshot) + require.NotNil(t, items[2].Snapshot) + require.Equal(t, 0.045, items[0].Snapshot.Data["effective_rate_multiplier"]) + require.Equal(t, 0.047, items[1].Snapshot.Data["effective_rate_multiplier"]) + require.Equal(t, UpstreamBillingProbeStatusFailed, items[2].Snapshot.Status) + require.Nil(t, items[3].Snapshot) +} + func upstreamBillingProbeValidBody() io.ReadCloser { return io.NopCloser(strings.NewReader(`{ "object":"sub2api.key_billing", diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ee053ad9ec..8de2ed4996 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -23,6 +23,7 @@ import type { CheckMixedChannelResponse, UpstreamBillingProbeResult, UpstreamBillingProbeSettings, + UpstreamBillingRatesResponse, OllamaCloudUsageSettings, OllamaCloudUsageState } from '@/types' @@ -70,6 +71,45 @@ export interface AccountListWithEtagResult { data: PaginatedResponse | null } +export interface AccountUpstreamBillingRatesWithEtagResult { + notModified: boolean + etag: string | null + data: UpstreamBillingRatesResponse | null +} + +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' + }, + options?: { + signal?: AbortSignal + etag?: string | null + } +): Promise { + const headers: Record = {} + if (options?.etag) headers['If-None-Match'] = options.etag + + const response = await apiClient.get('/admin/accounts/upstream-billing-rates', { + params: { page, page_size: pageSize, ...filters }, + headers, + signal: options?.signal, + validateStatus: (status) => (status >= 200 && status < 300) || status === 304 + }) + + const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null + if (response.status === 304) return { notModified: true, etag: etagHeader, data: null } + return { notModified: false, etag: etagHeader, data: response.data } +} + export async function listWithEtag( page: number = 1, pageSize: number = 20, @@ -1008,6 +1048,7 @@ export async function refreshOllamaCloudUsage(id: number): Promise()) const upstreamBillingProbeGloballyEnabled = ref(undefined) const upstreamBillingNow = ref(Date.now()) -let lastUpstreamBillingSortRefreshMinute = -1 +const upstreamBillingRateETag = ref(null) +const upstreamBillingRateRefreshing = ref(false) +let upstreamBillingRateAbortController: AbortController | null = null useIntervalFn(() => { upstreamBillingNow.value = Date.now() }, 60_000) // Account tools dropdown @@ -1137,19 +1139,17 @@ useSwipeSelect(accountTableRef, { const resetAutoRefreshCache = () => { autoRefreshETag.value = null + upstreamBillingRateETag.value = null } const isFirstLoad = ref(true) -function markUpstreamBillingSortRefresh() { - if (sortState.sort_by === 'upstream_billing_rate') { - lastUpstreamBillingSortRefreshMinute = Math.floor(Date.now() / 60_000) - } +type AccountLoadOptions = { + refreshTodayStats?: boolean } -const load = async () => { +const load = async (options: AccountLoadOptions = {}) => { const requestParams = params as any - markUpstreamBillingSortRefresh() syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() @@ -1162,11 +1162,10 @@ const load = async () => { isFirstLoad.value = false delete requestParams.lite } - await refreshTodayStatsBatch() + if (options.refreshTodayStats !== false) await refreshTodayStatsBatch() } const reload = async () => { - markUpstreamBillingSortRefresh() syncAccountListDerivedParams() hasPendingListSync.value = false resetAutoRefreshCache() @@ -1175,19 +1174,120 @@ const reload = async () => { await refreshTodayStatsBatch() } -const refreshUpstreamBillingSortedList = async (force = false) => { - if (sortState.sort_by !== 'upstream_billing_rate') return - - const minute = Math.floor(upstreamBillingNow.value / 60_000) - if (!force && lastUpstreamBillingSortRefreshMinute === minute) return - lastUpstreamBillingSortRefreshMinute = minute - try { - await reload() - } catch (error) { - console.error('Failed to refresh upstream billing sort:', error) +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 } } +const sameAccountIDOrder = (left: number[], right: number[]) => + left.length === right.length && left.every((id, index) => id === right[index]) + +const upstreamBillingRateContextKey = () => JSON.stringify({ + page: pagination.page, + pageSize: pagination.page_size, + filters: buildUpstreamBillingRateFilters() +}) + +const applyUpstreamBillingRateSnapshots = async ( + result: NonNullable>['data']> +) => { + const nextIDs = result.items.map(item => item.account_id) + const currentIDs = accounts.value.map(account => account.id) + + // The compact response cannot fill a row that crossed a page boundary. + // Only that case needs the expensive, full account-list request. + if (result.total !== pagination.total || !sameAccountIDOrder(nextIDs, currentIDs)) { + try { + await load({ refreshTodayStats: false }) + } catch (error) { + console.error('Failed to reconcile upstream billing sort:', error) + } + return + } + + const itemsByID = new Map(result.items.map(item => [item.account_id, item])) + let changed = false + const nextAccounts = accounts.value.map(account => { + const item = itemsByID.get(account.id) + if (!item) return account + const nextSnapshot = item.snapshot ?? null + const previousSnapshot = account.extra?.upstream_billing_probe ?? null + if (JSON.stringify(previousSnapshot) === JSON.stringify(nextSnapshot)) return account + + const nextExtra = { ...(account.extra ?? {}) } + if (nextSnapshot) nextExtra.upstream_billing_probe = nextSnapshot + else delete nextExtra.upstream_billing_probe + const nextAccount = { + ...account, + ...(typeof nextSnapshot?.synced_rate_multiplier === 'number' + ? { rate_multiplier: nextSnapshot.synced_rate_multiplier } + : {}), + extra: nextExtra + } + syncAccountRefs(nextAccount) + changed = true + return nextAccount + }) + + if (changed) { + accounts.value = nextAccounts + upstreamBillingNow.value = Date.now() + } +} + +const refreshUpstreamBillingRates = async (force = false) => { + if (upstreamBillingRateRefreshing.value || loading.value || accounts.value.length === 0) return + if (!force && ( + probingUpstreamBilling.size > 0 || + isAnyModalOpen.value || + menu.show || + showAccountToolsDropdown.value || + showAutoRefreshDropdown.value || + (typeof document !== 'undefined' && document.hidden) + )) return + + const controller = new AbortController() + upstreamBillingRateAbortController = controller + upstreamBillingRateRefreshing.value = true + try { + syncAccountListDerivedParams() + const requestContextKey = upstreamBillingRateContextKey() + const result = await adminAPI.accounts.getUpstreamBillingRatesWithEtag( + pagination.page, + pagination.page_size, + buildUpstreamBillingRateFilters(), + { etag: force ? null : upstreamBillingRateETag.value, signal: controller.signal } + ) + if (loading.value || requestContextKey !== upstreamBillingRateContextKey()) return + if (result.etag) upstreamBillingRateETag.value = result.etag + if (!result.notModified && result.data) await applyUpstreamBillingRateSnapshots(result.data) + } catch (error) { + const refreshError = error as { name?: string; code?: string } + if (refreshError.name !== 'AbortError' && refreshError.name !== 'CanceledError' && refreshError.code !== 'ERR_CANCELED') { + console.error('Failed to refresh upstream billing rates:', error) + } + } finally { + if (upstreamBillingRateAbortController === controller) upstreamBillingRateAbortController = null + upstreamBillingRateRefreshing.value = false + } +} + +const refreshUpstreamBillingSortedList = async (force = false) => { + if (!force && sortState.sort_by !== 'upstream_billing_rate') return + await refreshUpstreamBillingRates(force) +} + +useIntervalFn(() => { void refreshUpstreamBillingRates() }, 5 * 60_000, { immediate: false }) + const debouncedReload = () => { clearSelection() syncAccountListDerivedParams() @@ -1255,12 +1355,6 @@ watch(accounts, (rows) => { ) }) -watch(upstreamBillingNow, () => { - if (sortState.sort_by !== 'upstream_billing_rate' || loading.value) return - if (typeof document !== 'undefined' && document.hidden) return - void refreshUpstreamBillingSortedList() -}) - const isAnyModalOpen = computed(() => { return ( showCreate.value || @@ -1373,7 +1467,6 @@ const refreshAccountsIncrementally = async () => { pagination.pages = result.data.pages || 0 mergeAccountsIncrementally(result.data.items || []) hasPendingListSync.value = false - markUpstreamBillingSortRefresh() } upstreamBillingNow.value = Date.now() @@ -2150,19 +2243,17 @@ const patchAccountInList = (updatedAccount: Account) => { const patchUpstreamBillingSnapshot = (accountID: number, snapshot: UpstreamBillingProbeSnapshot) => { const account = accounts.value.find(item => item.id === accountID) if (!account) return - markUpstreamBillingSortRefresh() upstreamBillingNow.value = Date.now() patchAccountInList({ ...account, + ...(typeof snapshot.synced_rate_multiplier === 'number' + ? { rate_multiplier: snapshot.synced_rate_multiplier } + : {}), extra: { ...account.extra, upstream_billing_probe: snapshot } }) } const refreshAccountsAfterUpstreamBillingProbe = async () => { - try { - await load() - } catch (error) { - console.error('Failed to refresh accounts after upstream billing probe:', error) - } + await refreshUpstreamBillingSortedList(true) } const handleProbeUpstreamBilling = async (account: Account) => { if (probingUpstreamBilling.has(account.id)) return @@ -2481,6 +2572,7 @@ onMounted(async () => { }) onUnmounted(() => { + upstreamBillingRateAbortController?.abort() if (usageBatchFlushTimer !== null) { clearTimeout(usageBatchFlushTimer) usageBatchFlushTimer = null diff --git a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts index 6c6b87d97e..425fa5def7 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts @@ -6,6 +6,7 @@ import AccountsView from '../AccountsView.vue' const { listAccounts, listWithEtag, + getUpstreamBillingRatesWithEtag, getBatchTodayStats, getUpstreamBillingProbeSettings, getAllProxies, @@ -17,6 +18,7 @@ const { } = vi.hoisted(() => ({ listAccounts: vi.fn(), listWithEtag: vi.fn(), + getUpstreamBillingRatesWithEtag: vi.fn(), getBatchTodayStats: vi.fn(), getUpstreamBillingProbeSettings: vi.fn(), getAllProxies: vi.fn(), @@ -32,6 +34,7 @@ vi.mock('@/api/admin', () => ({ accounts: { list: listAccounts, listWithEtag, + getUpstreamBillingRatesWithEtag, getBatchTodayStats, getUpstreamBillingProbeSettings, delete: vi.fn(), @@ -127,6 +130,7 @@ describe('admin AccountsView bulk edit scope', () => { listAccounts.mockReset() listWithEtag.mockReset() + getUpstreamBillingRatesWithEtag.mockReset() getBatchTodayStats.mockReset() getUpstreamBillingProbeSettings.mockReset() getAllProxies.mockReset() @@ -148,6 +152,11 @@ describe('admin AccountsView bulk edit scope', () => { etag: null, data: null }) + getUpstreamBillingRatesWithEtag.mockResolvedValue({ + notModified: true, + etag: null, + data: null + }) getBatchTodayStats.mockResolvedValue({ stats: {} }) getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30 }) getAllProxies.mockResolvedValue([]) @@ -400,7 +409,7 @@ describe('admin AccountsView bulk edit scope', () => { expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([7, 11]) }) - it('refreshes the current page after a batch probe and displays the synced rate', async () => { + it('updates the current page locally after a batch probe', async () => { const account = (id: number, rateMultiplier: number) => ({ id, name: `account-${id}`, @@ -422,6 +431,7 @@ describe('admin AccountsView bulk edit scope', () => { snapshot: { status: 'ok', data: { effective_rate_multiplier: 0.065 }, + synced_rate_multiplier: 0.065, last_attempt_at: '2026-07-13T00:00:00Z', next_probe_at: '2026-07-13T00:30:00Z' } @@ -471,12 +481,11 @@ describe('admin AccountsView bulk edit scope', () => { await flushPromises() expect(probeUpstreamBillingBatch).toHaveBeenCalledWith([11]) - expect(listAccounts).toHaveBeenCalledTimes(3) - expect(listAccounts.mock.calls[2]?.[0]).toBe(2) + expect(listAccounts).toHaveBeenCalledTimes(2) expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x') }) - it('does not report a successful batch probe as failed when the list refresh fails', async () => { + it('does not report a successful batch probe as failed when reconciliation is skipped', async () => { const account = { id: 7, name: 'account-7', @@ -490,7 +499,6 @@ describe('admin AccountsView bulk edit scope', () => { } listAccounts .mockResolvedValueOnce({ items: [account], total: 1, page: 1, page_size: 20, pages: 1 }) - .mockRejectedValueOnce(new Error('refresh failed')) probeUpstreamBillingBatch.mockResolvedValue([ { account_id: 7, @@ -549,7 +557,7 @@ describe('admin AccountsView bulk edit scope', () => { consoleError.mockRestore() }) - it('refreshes the account row after a successful single-account probe', async () => { + it('updates the account row after a successful single-account probe', async () => { const account = (rateMultiplier: number) => ({ id: 7, name: 'account-7', @@ -570,6 +578,7 @@ describe('admin AccountsView bulk edit scope', () => { snapshot: { status: 'ok', data: { effective_rate_multiplier: 0.065 }, + synced_rate_multiplier: 0.065, last_attempt_at: '2026-07-13T00:00:00Z', next_probe_at: '2026-07-13T00:30:00Z' } @@ -615,7 +624,7 @@ describe('admin AccountsView bulk edit scope', () => { await flushPromises() expect(probeUpstreamBilling).toHaveBeenCalledWith(7) - expect(listAccounts).toHaveBeenCalledTimes(2) + expect(listAccounts).toHaveBeenCalledTimes(1) expect(wrapper.get('[data-test="account-rate"]').text()).toBe('0.065x') }) })