From f0cb02afb8ad8d2873cc327555b63a8336cab625 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Thu, 21 May 2026 23:23:06 +0200 Subject: [PATCH] feat(usage): attribute Sources rows to user accounts in admin view (#9935) The merged feature (#9920) let admins see per-API-key and per-source totals but did not surface which user owned each key, and lumped every user's Web UI traffic into a single global Web UI row. This makes the admin Sources tab properly per-user attributable: - KeyTotal gains UserID + UserName, populated from the snapshot the usage middleware already records. The by_key roll-up now groups by (api_key_id, api_key_name, user_id, user_name). - New SourceTotals.ByUserSource roll-up groups (source, user_id, user_name) for sources without a key identity (web, legacy). Only populated on the admin path (includeLegacy=true); the non-admin endpoint stays unchanged for backwards compatibility. - SourcesTable accepts showUserColumn={isAdmin}; admin view renders a User column, makes the search match user name/id, and expands Web UI / legacy pseudo-rows from the global aggregate to one row per user using by_user_source. Refs: #9862 Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/auth/usage.go | 57 +++++++++++-- core/http/auth/usage_test.go | 81 +++++++++++++++++++ .../react-ui/public/locales/en/admin.json | 1 + .../react-ui/src/pages/Usage/SourcesTab.jsx | 1 + .../react-ui/src/pages/Usage/SourcesTable.jsx | 64 ++++++++++++--- 5 files changed, 185 insertions(+), 19 deletions(-) diff --git a/core/http/auth/usage.go b/core/http/auth/usage.go index 98c11093e..44ccf840e 100644 --- a/core/http/auth/usage.go +++ b/core/http/auth/usage.go @@ -198,20 +198,37 @@ type TotalsEntry struct { Requests int64 `json:"requests"` } -// KeyTotal is the per-key roll-up returned by sources endpoints. +// KeyTotal is the per-key roll-up returned by sources endpoints. UserID and +// UserName are snapshotted from the UsageRecord so revoked-and-deleted keys +// still carry their owner attribution in admin views. type KeyTotal struct { APIKeyID string `json:"api_key_id"` APIKeyName string `json:"api_key_name"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` Tokens int64 `json:"tokens"` Requests int64 `json:"requests"` LastUsed time.Time `json:"last_used"` } +// UserSourceTotal is a per-(user, source) roll-up for sources that don't carry +// a named API key identity (web, legacy). It exists so admin views can show +// which user generated each block of Web UI / legacy traffic; the per-apikey +// breakdown for source=apikey already lives in KeyTotal. +type UserSourceTotal struct { + Source string `json:"source"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Tokens int64 `json:"tokens"` + Requests int64 `json:"requests"` +} + // SourceTotals summarises a per-source breakdown. type SourceTotals struct { - BySource map[string]TotalsEntry `json:"by_source"` - ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped - GrandTotal TotalsEntry `json:"grand_total"` + BySource map[string]TotalsEntry `json:"by_source"` + ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped + ByUserSource []UserSourceTotal `json:"by_user_source,omitempty"` // populated only when includeLegacy=true + GrandTotal TotalsEntry `json:"grand_total"` } const maxKeyTotals = 200 @@ -275,9 +292,10 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time, byKeyQ := db.Model(&UsageRecord{}). Select("COALESCE(api_key_id, '') as api_key_id, api_key_name, "+ + "user_id, user_name, "+ "SUM(total_tokens) as tokens, COUNT(*) as requests, MAX(created_at) as last_used"). Where("api_key_id IS NOT NULL AND api_key_id <> ''"). - Group("api_key_id, api_key_name"). + Group("api_key_id, api_key_name, user_id, user_name"). Order("tokens DESC"). Limit(maxKeyTotals) byKeyQ = applyFilters(byKeyQ, userID, apiKeyID, since, includeLegacy) @@ -294,15 +312,17 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time, out := make([]KeyTotal, 0) for rows.Next() { var ( - apiKeyID, apiKeyName, lastUsedRaw string - tokens, requests int64 + apiKeyID, apiKeyName, userIDCol, userName, lastUsedRaw string + tokens, requests int64 ) - if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &tokens, &requests, &lastUsedRaw); scanErr != nil { + if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &userIDCol, &userName, &tokens, &requests, &lastUsedRaw); scanErr != nil { continue } out = append(out, KeyTotal{ APIKeyID: apiKeyID, APIKeyName: apiKeyName, + UserID: userIDCol, + UserName: userName, Tokens: tokens, Requests: requests, LastUsed: parseLastUsedString(lastUsedRaw), @@ -314,6 +334,27 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time, totals.ByKey = out } + // by_user_source: only populated for admin callers (includeLegacy=true) so + // they can attribute Web UI / legacy traffic to specific users. Per-apikey + // rows already carry user info via KeyTotal above, so this query only + // covers source != apikey. + if includeLegacy { + byUserSourceQ := db.Model(&UsageRecord{}). + Select("source, user_id, user_name, "+ + "SUM(total_tokens) as tokens, COUNT(*) as requests"). + Where("source <> ?", UsageSourceAPIKey). + Group("source, user_id, user_name"). + Order("tokens DESC") + byUserSourceQ = applyFilters(byUserSourceQ, userID, apiKeyID, since, includeLegacy) + + var byUserSourceRows []UserSourceTotal + if scanErr := byUserSourceQ.Scan(&byUserSourceRows).Error; scanErr != nil { + xlog.Warn("computeSourceTotals: by-user-source Scan failed", "error", scanErr) + } else { + totals.ByUserSource = byUserSourceRows + } + } + return totals } diff --git a/core/http/auth/usage_test.go b/core/http/auth/usage_test.go index 7b8a457a2..bb3dc945d 100644 --- a/core/http/auth/usage_test.go +++ b/core/http/auth/usage_test.go @@ -349,5 +349,86 @@ var _ = Describe("Usage", func() { Expect(totals.ByKey).To(HaveLen(200)) Expect(totals.ByKey[0].Tokens > totals.ByKey[199].Tokens).To(BeTrue()) }) + + // insertNamed records a row with explicit user_id, user_name, source, + // and optional api key snapshot. Used by the user-attribution tests + // below which the older insert helper can't express. + insertNamed := func(db *gorm.DB, userID, userName, source, keyID, keyName string, tokens int64) { + rec := &auth.UsageRecord{ + UserID: userID, + UserName: userName, + Source: source, + Model: "gpt-4", + TotalTokens: tokens, + CreatedAt: time.Now(), + } + if keyID != "" { + rec.APIKeyID = &keyID + rec.APIKeyName = keyName + } + Expect(auth.RecordUsage(db, rec)).To(Succeed()) + } + + It("attributes each KeyTotal to its owner user", func() { + db := testDB() + insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 100) + insertNamed(db, "bob", "Bob", auth.UsageSourceAPIKey, "k2", "lap", 50) + + _, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "") + Expect(err).ToNot(HaveOccurred()) + Expect(totals.ByKey).To(HaveLen(2)) + + byID := map[string]auth.KeyTotal{} + for _, k := range totals.ByKey { + byID[k.APIKeyID] = k + } + Expect(byID["k1"].UserID).To(Equal("alice")) + Expect(byID["k1"].UserName).To(Equal("Alice")) + Expect(byID["k2"].UserID).To(Equal("bob")) + Expect(byID["k2"].UserName).To(Equal("Bob")) + }) + + It("breaks Web UI and legacy traffic out per user in by_user_source for admin", func() { + db := testDB() + // Alice and Bob both have Web UI traffic; a synthetic legacy user + // also contributes. ByUserSource should expose one row per + // (source, user) pair, never for source=apikey. + insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30) + insertNamed(db, "bob", "Bob", auth.UsageSourceWeb, "", "", 70) + insertNamed(db, "legacy-api-key", "API Key User", auth.UsageSourceLegacy, "", "", 10) + insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 5) + + _, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "") + Expect(err).ToNot(HaveOccurred()) + Expect(totals.ByUserSource).ToNot(BeEmpty()) + + for _, r := range totals.ByUserSource { + Expect(r.Source).ToNot(Equal(auth.UsageSourceAPIKey)) + } + + webByUser := map[string]int64{} + legacyByUser := map[string]int64{} + for _, r := range totals.ByUserSource { + switch r.Source { + case auth.UsageSourceWeb: + webByUser[r.UserID] = r.Tokens + case auth.UsageSourceLegacy: + legacyByUser[r.UserID] = r.Tokens + } + } + Expect(webByUser["alice"]).To(Equal(int64(30))) + Expect(webByUser["bob"]).To(Equal(int64(70))) + Expect(legacyByUser["legacy-api-key"]).To(Equal(int64(10))) + }) + + It("does NOT populate by_user_source in the non-admin path", func() { + db := testDB() + insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30) + + _, totals, err := auth.GetUserUsageBySource(db, "alice", "month") + Expect(err).ToNot(HaveOccurred()) + // Non-admin path uses includeLegacy=false, so by_user_source stays nil. + Expect(totals.ByUserSource).To(BeNil()) + }) }) }) diff --git a/core/http/react-ui/public/locales/en/admin.json b/core/http/react-ui/public/locales/en/admin.json index 4b5ce0bb0..f4a380ae3 100644 --- a/core/http/react-ui/public/locales/en/admin.json +++ b/core/http/react-ui/public/locales/en/admin.json @@ -65,6 +65,7 @@ "sortRequests": "Requests", "sortLastUsed": "Last used", "sortName": "Name", + "sortUser": "User", "webUI": "Web UI", "legacy": "Legacy", "revoked": "revoked", diff --git a/core/http/react-ui/src/pages/Usage/SourcesTab.jsx b/core/http/react-ui/src/pages/Usage/SourcesTab.jsx index 79e5e93d4..56695f31f 100644 --- a/core/http/react-ui/src/pages/Usage/SourcesTab.jsx +++ b/core/http/react-ui/src/pages/Usage/SourcesTab.jsx @@ -162,6 +162,7 @@ export default function SourcesTab({ period, adminUserId }) { sortKey={sortKey} setSortKey={setSortKey} existingKeyIds={existingKeyIds} + showUserColumn={isAdmin} /> diff --git a/core/http/react-ui/src/pages/Usage/SourcesTable.jsx b/core/http/react-ui/src/pages/Usage/SourcesTable.jsx index 00abc5f75..4056c6a46 100644 --- a/core/http/react-ui/src/pages/Usage/SourcesTable.jsx +++ b/core/http/react-ui/src/pages/Usage/SourcesTable.jsx @@ -6,6 +6,7 @@ const SORT_FNS = { requests: (a, b) => (b.requests || 0) - (a.requests || 0), last_used: (a, b) => new Date(b.last_used || 0).getTime() - new Date(a.last_used || 0).getTime(), name: (a, b) => (a.name || '').localeCompare(b.name || ''), + user: (a, b) => (a.userName || '').localeCompare(b.userName || ''), } function formatTokens(n) { @@ -41,6 +42,8 @@ function formatRelative(iso) { // when the parent hasn't yet learned which keys exist. Null suppresses the // revoked badge entirely so live keys aren't dimmed during the fetch or // after a failure. +// showUserColumn: render the User column. Admin views set this true so the +// reader can attribute each key (and each Web UI row) to its owner. export default function SourcesTable({ totals, selectedKey, @@ -50,6 +53,7 @@ export default function SourcesTable({ sortKey, setSortKey, existingKeyIds = null, + showUserColumn = false, }) { const { t } = useTranslation('admin') @@ -58,39 +62,70 @@ export default function SourcesTable({ kind: 'apikey', id: k.api_key_id, name: k.api_key_name || k.api_key_id, + userID: k.user_id || '', + userName: k.user_name || '', prefix: '', tokens: k.tokens, requests: k.requests, last_used: k.last_used, revoked: existingKeyIds != null && !existingKeyIds.has(k.api_key_id), })) - const web = totals?.by_source?.web - ? [{ + + // Pseudo-rows for sources that don't have a named key identity. + // In admin view (showUserColumn=true), prefer the per-user breakdown + // from totals.by_user_source so each user's Web UI / legacy traffic + // gets its own row. Otherwise fall back to the global by_source aggregate. + let unkeyed = [] + if (showUserColumn && Array.isArray(totals?.by_user_source) && totals.by_user_source.length > 0) { + unkeyed = totals.by_user_source.map((r) => ({ + kind: r.source, + id: r.source + ':' + (r.user_id || ''), + name: r.source === 'legacy' ? t('usage.sources.legacy') : t('usage.sources.webUI'), + userID: r.user_id || '', + userName: r.user_name || '', + prefix: '-', + tokens: r.tokens, + requests: r.requests, + })) + } else { + if (totals?.by_source?.web) { + unkeyed.push({ kind: 'web', id: 'web', name: t('usage.sources.webUI'), + userID: '', + userName: '', prefix: '-', tokens: totals.by_source.web.tokens, requests: totals.by_source.web.requests, - }] - : [] - const leg = totals?.by_source?.legacy - ? [{ + }) + } + if (totals?.by_source?.legacy) { + unkeyed.push({ kind: 'legacy', id: 'legacy', name: t('usage.sources.legacy'), + userID: '', + userName: '', prefix: '-', tokens: totals.by_source.legacy.tokens, requests: totals.by_source.legacy.requests, - }] - : [] - return [...named, ...web, ...leg] - }, [totals, existingKeyIds, t]) + }) + } + } + + return [...named, ...unkeyed] + }, [totals, existingKeyIds, showUserColumn, t]) const filtered = useMemo(() => { const q = (search || '').trim().toLowerCase() const list = q - ? rows.filter((r) => (r.name || '').toLowerCase().includes(q) || (r.prefix || '').toLowerCase().includes(q)) + ? rows.filter((r) => + (r.name || '').toLowerCase().includes(q) || + (r.prefix || '').toLowerCase().includes(q) || + (r.userName || '').toLowerCase().includes(q) || + (r.userID || '').toLowerCase().includes(q) + ) : rows return [...list].sort(SORT_FNS[sortKey] || SORT_FNS.tokens) }, [rows, search, sortKey]) @@ -134,6 +169,7 @@ export default function SourcesTable({ + {showUserColumn && } @@ -143,6 +179,7 @@ export default function SourcesTable({ {t('usage.sources.sortName')} + {showUserColumn && {t('usage.sources.sortUser')}} Prefix {t('usage.sources.sortRequests')} {t('usage.sources.sortTokens')} @@ -182,6 +219,11 @@ export default function SourcesTable({ )} + {showUserColumn && ( + + {r.userName || r.userID || '-'} + + )} {r.prefix || '-'} {Number(r.requests || 0).toLocaleString()}