feat(workshop): retain daily assistant quotas

This commit is contained in:
ajnart committed 2026-08-25 17:11:51 +02:00
1 parent 978012bee7
commit 82f77db5c0
6 files changed
+72 -9

No files matched your search

+4 -2
View File
@@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
We would rather become millionaires in a more ethical manner.
+3
View File
@@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
+7 -5
View File
@@ -605,7 +605,12 @@ func (provider *homarrProvider) loadQuota(
user *core.Record,
now time.Time,
) (*core.Record, quotaSnapshot, bool, error) {
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
day := now.Format(time.DateOnly)
quota, err := app.FindFirstRecordByFilter(
"assistant_quotas",
"user = {:user} && day = {:day}",
dbx.Params{"user": user.Id, "day": day},
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, quotaSnapshot{}, false, err
}
@@ -617,10 +622,7 @@ func (provider *homarrProvider) loadQuota(
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("day", day)
quota.Set("used", 0)
changed = true
}
@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user");
quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", "");
app.save(quotas);
},
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user_day");
// The previous schema can keep only one record per user. Retain the latest UTC day when rolling back.
app
.db()
.newQuery(`
DELETE FROM assistant_quotas
WHERE id NOT IN (
SELECT (
SELECT candidate.id
FROM assistant_quotas candidate
WHERE candidate.user = quota_user.user
ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC
LIMIT 1
)
FROM assistant_quotas quota_user
GROUP BY quota_user.user
)
`)
.execute();
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
},
);
@@ -82,6 +82,14 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
+14 -2
View File
@@ -436,9 +436,21 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const resetQuota = (
const today = new Date().toISOString().slice(0, 10);
const resetQuotas = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.find((item) => item.user === author.id);
).items.filter((item) => item.user === author.id);
const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id);
const resetQuota = resetQuotas.find((item) => item.day === today);
if (
resetQuotas.length !== 2 ||
historicalQuota?.day !== "2000-01-01" ||
historicalQuota.used !== 49 ||
!resetQuota ||
resetQuota.used !== 0
) {
throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`);
}
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,