Skip to content

Commit f0cb02a

Browse files
localai-botmudler
andauthored
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 <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent a39e025 commit f0cb02a

5 files changed

Lines changed: 185 additions & 19 deletions

File tree

core/http/auth/usage.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -198,20 +198,37 @@ type TotalsEntry struct {
198198
Requests int64 `json:"requests"`
199199
}
200200

201-
// KeyTotal is the per-key roll-up returned by sources endpoints.
201+
// KeyTotal is the per-key roll-up returned by sources endpoints. UserID and
202+
// UserName are snapshotted from the UsageRecord so revoked-and-deleted keys
203+
// still carry their owner attribution in admin views.
202204
type KeyTotal struct {
203205
APIKeyID string `json:"api_key_id"`
204206
APIKeyName string `json:"api_key_name"`
207+
UserID string `json:"user_id"`
208+
UserName string `json:"user_name"`
205209
Tokens int64 `json:"tokens"`
206210
Requests int64 `json:"requests"`
207211
LastUsed time.Time `json:"last_used"`
208212
}
209213

214+
// UserSourceTotal is a per-(user, source) roll-up for sources that don't carry
215+
// a named API key identity (web, legacy). It exists so admin views can show
216+
// which user generated each block of Web UI / legacy traffic; the per-apikey
217+
// breakdown for source=apikey already lives in KeyTotal.
218+
type UserSourceTotal struct {
219+
Source string `json:"source"`
220+
UserID string `json:"user_id"`
221+
UserName string `json:"user_name"`
222+
Tokens int64 `json:"tokens"`
223+
Requests int64 `json:"requests"`
224+
}
225+
210226
// SourceTotals summarises a per-source breakdown.
211227
type SourceTotals struct {
212-
BySource map[string]TotalsEntry `json:"by_source"`
213-
ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped
214-
GrandTotal TotalsEntry `json:"grand_total"`
228+
BySource map[string]TotalsEntry `json:"by_source"`
229+
ByKey []KeyTotal `json:"by_key"` // server-sorted desc by tokens, capped
230+
ByUserSource []UserSourceTotal `json:"by_user_source,omitempty"` // populated only when includeLegacy=true
231+
GrandTotal TotalsEntry `json:"grand_total"`
215232
}
216233

217234
const maxKeyTotals = 200
@@ -275,9 +292,10 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
275292

276293
byKeyQ := db.Model(&UsageRecord{}).
277294
Select("COALESCE(api_key_id, '') as api_key_id, api_key_name, "+
295+
"user_id, user_name, "+
278296
"SUM(total_tokens) as tokens, COUNT(*) as requests, MAX(created_at) as last_used").
279297
Where("api_key_id IS NOT NULL AND api_key_id <> ''").
280-
Group("api_key_id, api_key_name").
298+
Group("api_key_id, api_key_name, user_id, user_name").
281299
Order("tokens DESC").
282300
Limit(maxKeyTotals)
283301
byKeyQ = applyFilters(byKeyQ, userID, apiKeyID, since, includeLegacy)
@@ -294,15 +312,17 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
294312
out := make([]KeyTotal, 0)
295313
for rows.Next() {
296314
var (
297-
apiKeyID, apiKeyName, lastUsedRaw string
298-
tokens, requests int64
315+
apiKeyID, apiKeyName, userIDCol, userName, lastUsedRaw string
316+
tokens, requests int64
299317
)
300-
if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &tokens, &requests, &lastUsedRaw); scanErr != nil {
318+
if scanErr := rows.Scan(&apiKeyID, &apiKeyName, &userIDCol, &userName, &tokens, &requests, &lastUsedRaw); scanErr != nil {
301319
continue
302320
}
303321
out = append(out, KeyTotal{
304322
APIKeyID: apiKeyID,
305323
APIKeyName: apiKeyName,
324+
UserID: userIDCol,
325+
UserName: userName,
306326
Tokens: tokens,
307327
Requests: requests,
308328
LastUsed: parseLastUsedString(lastUsedRaw),
@@ -314,6 +334,27 @@ func computeSourceTotals(db *gorm.DB, userID, apiKeyID string, since time.Time,
314334
totals.ByKey = out
315335
}
316336

337+
// by_user_source: only populated for admin callers (includeLegacy=true) so
338+
// they can attribute Web UI / legacy traffic to specific users. Per-apikey
339+
// rows already carry user info via KeyTotal above, so this query only
340+
// covers source != apikey.
341+
if includeLegacy {
342+
byUserSourceQ := db.Model(&UsageRecord{}).
343+
Select("source, user_id, user_name, "+
344+
"SUM(total_tokens) as tokens, COUNT(*) as requests").
345+
Where("source <> ?", UsageSourceAPIKey).
346+
Group("source, user_id, user_name").
347+
Order("tokens DESC")
348+
byUserSourceQ = applyFilters(byUserSourceQ, userID, apiKeyID, since, includeLegacy)
349+
350+
var byUserSourceRows []UserSourceTotal
351+
if scanErr := byUserSourceQ.Scan(&byUserSourceRows).Error; scanErr != nil {
352+
xlog.Warn("computeSourceTotals: by-user-source Scan failed", "error", scanErr)
353+
} else {
354+
totals.ByUserSource = byUserSourceRows
355+
}
356+
}
357+
317358
return totals
318359
}
319360

core/http/auth/usage_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,5 +349,86 @@ var _ = Describe("Usage", func() {
349349
Expect(totals.ByKey).To(HaveLen(200))
350350
Expect(totals.ByKey[0].Tokens > totals.ByKey[199].Tokens).To(BeTrue())
351351
})
352+
353+
// insertNamed records a row with explicit user_id, user_name, source,
354+
// and optional api key snapshot. Used by the user-attribution tests
355+
// below which the older insert helper can't express.
356+
insertNamed := func(db *gorm.DB, userID, userName, source, keyID, keyName string, tokens int64) {
357+
rec := &auth.UsageRecord{
358+
UserID: userID,
359+
UserName: userName,
360+
Source: source,
361+
Model: "gpt-4",
362+
TotalTokens: tokens,
363+
CreatedAt: time.Now(),
364+
}
365+
if keyID != "" {
366+
rec.APIKeyID = &keyID
367+
rec.APIKeyName = keyName
368+
}
369+
Expect(auth.RecordUsage(db, rec)).To(Succeed())
370+
}
371+
372+
It("attributes each KeyTotal to its owner user", func() {
373+
db := testDB()
374+
insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 100)
375+
insertNamed(db, "bob", "Bob", auth.UsageSourceAPIKey, "k2", "lap", 50)
376+
377+
_, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "")
378+
Expect(err).ToNot(HaveOccurred())
379+
Expect(totals.ByKey).To(HaveLen(2))
380+
381+
byID := map[string]auth.KeyTotal{}
382+
for _, k := range totals.ByKey {
383+
byID[k.APIKeyID] = k
384+
}
385+
Expect(byID["k1"].UserID).To(Equal("alice"))
386+
Expect(byID["k1"].UserName).To(Equal("Alice"))
387+
Expect(byID["k2"].UserID).To(Equal("bob"))
388+
Expect(byID["k2"].UserName).To(Equal("Bob"))
389+
})
390+
391+
It("breaks Web UI and legacy traffic out per user in by_user_source for admin", func() {
392+
db := testDB()
393+
// Alice and Bob both have Web UI traffic; a synthetic legacy user
394+
// also contributes. ByUserSource should expose one row per
395+
// (source, user) pair, never for source=apikey.
396+
insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30)
397+
insertNamed(db, "bob", "Bob", auth.UsageSourceWeb, "", "", 70)
398+
insertNamed(db, "legacy-api-key", "API Key User", auth.UsageSourceLegacy, "", "", 10)
399+
insertNamed(db, "alice", "Alice", auth.UsageSourceAPIKey, "k1", "ci-runner", 5)
400+
401+
_, totals, _, err := auth.GetAllUsageBySource(db, "month", "", "")
402+
Expect(err).ToNot(HaveOccurred())
403+
Expect(totals.ByUserSource).ToNot(BeEmpty())
404+
405+
for _, r := range totals.ByUserSource {
406+
Expect(r.Source).ToNot(Equal(auth.UsageSourceAPIKey))
407+
}
408+
409+
webByUser := map[string]int64{}
410+
legacyByUser := map[string]int64{}
411+
for _, r := range totals.ByUserSource {
412+
switch r.Source {
413+
case auth.UsageSourceWeb:
414+
webByUser[r.UserID] = r.Tokens
415+
case auth.UsageSourceLegacy:
416+
legacyByUser[r.UserID] = r.Tokens
417+
}
418+
}
419+
Expect(webByUser["alice"]).To(Equal(int64(30)))
420+
Expect(webByUser["bob"]).To(Equal(int64(70)))
421+
Expect(legacyByUser["legacy-api-key"]).To(Equal(int64(10)))
422+
})
423+
424+
It("does NOT populate by_user_source in the non-admin path", func() {
425+
db := testDB()
426+
insertNamed(db, "alice", "Alice", auth.UsageSourceWeb, "", "", 30)
427+
428+
_, totals, err := auth.GetUserUsageBySource(db, "alice", "month")
429+
Expect(err).ToNot(HaveOccurred())
430+
// Non-admin path uses includeLegacy=false, so by_user_source stays nil.
431+
Expect(totals.ByUserSource).To(BeNil())
432+
})
352433
})
353434
})

core/http/react-ui/public/locales/en/admin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"sortRequests": "Requests",
6666
"sortLastUsed": "Last used",
6767
"sortName": "Name",
68+
"sortUser": "User",
6869
"webUI": "Web UI",
6970
"legacy": "Legacy",
7071
"revoked": "revoked",

core/http/react-ui/src/pages/Usage/SourcesTab.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export default function SourcesTab({ period, adminUserId }) {
162162
sortKey={sortKey}
163163
setSortKey={setSortKey}
164164
existingKeyIds={existingKeyIds}
165+
showUserColumn={isAdmin}
165166
/>
166167
</div>
167168

core/http/react-ui/src/pages/Usage/SourcesTable.jsx

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const SORT_FNS = {
66
requests: (a, b) => (b.requests || 0) - (a.requests || 0),
77
last_used: (a, b) => new Date(b.last_used || 0).getTime() - new Date(a.last_used || 0).getTime(),
88
name: (a, b) => (a.name || '').localeCompare(b.name || ''),
9+
user: (a, b) => (a.userName || '').localeCompare(b.userName || ''),
910
}
1011

1112
function formatTokens(n) {
@@ -41,6 +42,8 @@ function formatRelative(iso) {
4142
// when the parent hasn't yet learned which keys exist. Null suppresses the
4243
// revoked badge entirely so live keys aren't dimmed during the fetch or
4344
// after a failure.
45+
// showUserColumn: render the User column. Admin views set this true so the
46+
// reader can attribute each key (and each Web UI row) to its owner.
4447
export default function SourcesTable({
4548
totals,
4649
selectedKey,
@@ -50,6 +53,7 @@ export default function SourcesTable({
5053
sortKey,
5154
setSortKey,
5255
existingKeyIds = null,
56+
showUserColumn = false,
5357
}) {
5458
const { t } = useTranslation('admin')
5559

@@ -58,39 +62,70 @@ export default function SourcesTable({
5862
kind: 'apikey',
5963
id: k.api_key_id,
6064
name: k.api_key_name || k.api_key_id,
65+
userID: k.user_id || '',
66+
userName: k.user_name || '',
6167
prefix: '',
6268
tokens: k.tokens,
6369
requests: k.requests,
6470
last_used: k.last_used,
6571
revoked: existingKeyIds != null && !existingKeyIds.has(k.api_key_id),
6672
}))
67-
const web = totals?.by_source?.web
68-
? [{
73+
74+
// Pseudo-rows for sources that don't have a named key identity.
75+
// In admin view (showUserColumn=true), prefer the per-user breakdown
76+
// from totals.by_user_source so each user's Web UI / legacy traffic
77+
// gets its own row. Otherwise fall back to the global by_source aggregate.
78+
let unkeyed = []
79+
if (showUserColumn && Array.isArray(totals?.by_user_source) && totals.by_user_source.length > 0) {
80+
unkeyed = totals.by_user_source.map((r) => ({
81+
kind: r.source,
82+
id: r.source + ':' + (r.user_id || ''),
83+
name: r.source === 'legacy' ? t('usage.sources.legacy') : t('usage.sources.webUI'),
84+
userID: r.user_id || '',
85+
userName: r.user_name || '',
86+
prefix: '-',
87+
tokens: r.tokens,
88+
requests: r.requests,
89+
}))
90+
} else {
91+
if (totals?.by_source?.web) {
92+
unkeyed.push({
6993
kind: 'web',
7094
id: 'web',
7195
name: t('usage.sources.webUI'),
96+
userID: '',
97+
userName: '',
7298
prefix: '-',
7399
tokens: totals.by_source.web.tokens,
74100
requests: totals.by_source.web.requests,
75-
}]
76-
: []
77-
const leg = totals?.by_source?.legacy
78-
? [{
101+
})
102+
}
103+
if (totals?.by_source?.legacy) {
104+
unkeyed.push({
79105
kind: 'legacy',
80106
id: 'legacy',
81107
name: t('usage.sources.legacy'),
108+
userID: '',
109+
userName: '',
82110
prefix: '-',
83111
tokens: totals.by_source.legacy.tokens,
84112
requests: totals.by_source.legacy.requests,
85-
}]
86-
: []
87-
return [...named, ...web, ...leg]
88-
}, [totals, existingKeyIds, t])
113+
})
114+
}
115+
}
116+
117+
return [...named, ...unkeyed]
118+
}, [totals, existingKeyIds, showUserColumn, t])
89119

90120
const filtered = useMemo(() => {
91121
const q = (search || '').trim().toLowerCase()
92122
const list = q
93-
? rows.filter((r) => (r.name || '').toLowerCase().includes(q) || (r.prefix || '').toLowerCase().includes(q))
123+
? rows.filter((r) =>
124+
(r.name || '').toLowerCase().includes(q) ||
125+
(r.prefix || '').toLowerCase().includes(q) ||
126+
(r.userName || '').toLowerCase().includes(q) ||
127+
(r.userID || '').toLowerCase().includes(q)
128+
)
94129
: rows
95130
return [...list].sort(SORT_FNS[sortKey] || SORT_FNS.tokens)
96131
}, [rows, search, sortKey])
@@ -134,6 +169,7 @@ export default function SourcesTable({
134169
<option value="requests">{t('usage.sources.sortRequests')}</option>
135170
<option value="last_used">{t('usage.sources.sortLastUsed')}</option>
136171
<option value="name">{t('usage.sources.sortName')}</option>
172+
{showUserColumn && <option value="user">{t('usage.sources.sortUser')}</option>}
137173
</select>
138174
</label>
139175
</div>
@@ -143,6 +179,7 @@ export default function SourcesTable({
143179
<thead>
144180
<tr>
145181
<th>{t('usage.sources.sortName')}</th>
182+
{showUserColumn && <th style={{ width: 180 }}>{t('usage.sources.sortUser')}</th>}
146183
<th style={{ width: 110 }}>Prefix</th>
147184
<th style={{ width: 100, textAlign: 'right' }}>{t('usage.sources.sortRequests')}</th>
148185
<th style={{ width: 100, textAlign: 'right' }}>{t('usage.sources.sortTokens')}</th>
@@ -182,6 +219,11 @@ export default function SourcesTable({
182219
)}
183220
</span>
184221
</td>
222+
{showUserColumn && (
223+
<td style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem' }}>
224+
{r.userName || r.userID || '-'}
225+
</td>
226+
)}
185227
<td style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>{r.prefix || '-'}</td>
186228
<td style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>
187229
{Number(r.requests || 0).toLocaleString()}

0 commit comments

Comments
 (0)