Grants that outlive what they name, and a rule you can read
sharing.forget_principal has existed since shares did, documented as the thing that stops a recycled id inheriting somebody's grant, and was called by nobody. Deleting a group left every grant naming it; deleting an account left both the grants to it and the grants of its own work -- that second half is the one nothing else could catch, since their rows cascade and the shares of those rows have nothing to cascade from. Both now run before the delete, while the rows are still findable, and a deleted resource forgets its own. library.share defaulted to False, which meant sharing shipped documented as done and unreachable: the panel only renders for somebody holding it, so out of the box nobody could share anything and nothing said why. It is on. The panel itself was checkboxes inside the resource's *save form*, listing every group and every account on the instance, unpaginated, on every detail page -- and a tick only took effect if you also saved the resource. It is its own routes now: search, one grant per POST, the panel re-rendered from what is stored. Anything already shared stays listed whatever the search says, or removing a grant would mean searching for the name it was given to. Reports join the shareable set and memories still do not: a finished piece of work is the thing somebody most wants to hand over, and a record about a person is not content to pass round. reports.visible became sharing.visible_to, which is the one line its own docstring predicted. Two things fell out: `owned` beside `get`, because sharing grants reading and deleting is the owner's alone; and reading somebody else's report no longer clears their unread dot. Permissions gained the answer to "what can this person actually do?" -- explain() is resolve()'s working shown rather than thrown away, naming admin, the baseline, or the groups that granted each one. That is the simulation the union rule exists to make unnecessary, and until now the only way to get it was to open every group and read the grids by eye. Users and groups are list-plus-detail, and membership is edited from one side: it was on both, and a full-form POST from either overwrote what the other had shown. Read and write are split for notes, memory and skills -- checked on the tool's declared risk, after the gate so it can only narrow, and defaulting on. Quotas are the union rule applied to numbers, with the corner that makes it interesting: zero means "no limit" and wins outright, or a group saying unlimited would count for less than one saying a million. Absent means "no opinion". _narrower folds a group's ceiling with the instance's and is deliberately not min, for the same reason. Five axes, enforced where each is knowable -- before a reply is built, before a second one starts, on an agent reply's clock, before a minute of GPU, and beside the helper cap -- and usage is recorded even for a reply that was stopped or errored, because an endpoint charges either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
@@ -36,6 +37,7 @@ from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import push as push_service
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
from lembas.services.agent import session as agent_session
|
||||
from lembas.services.agent import tools as agent_tools
|
||||
@@ -437,6 +439,21 @@ async def shutdown() -> None:
|
||||
await task
|
||||
|
||||
|
||||
def _narrower(instance: float, quota: int) -> float:
|
||||
"""The tighter of two ceilings, where **zero means no limit**.
|
||||
|
||||
Not `min`: a zero on either side would win and turn "no opinion" into "no
|
||||
time at all". Written once and used wherever a group's number meets the
|
||||
instance's, because getting it wrong in one of those places is a limit that
|
||||
silently stops working.
|
||||
"""
|
||||
if instance <= 0:
|
||||
return float(quota)
|
||||
if quota <= 0:
|
||||
return float(instance)
|
||||
return float(min(instance, quota))
|
||||
|
||||
|
||||
async def _run(generation: Generation) -> None:
|
||||
"""Produce one reply, then persist it. Never raises into the task.
|
||||
|
||||
@@ -483,6 +500,16 @@ async def _run(generation: Generation) -> None:
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
owner = db.get(User, chat.user_id)
|
||||
|
||||
# Before the request is built, not while it streams. Every other
|
||||
# budget here can only be noticed part way through and so ends with
|
||||
# `_wrap_up` asking for a final answer; this one is knowable in
|
||||
# advance, and a reply that trails off because a month ran out mid
|
||||
# sentence would be the failure `_wrap_up` exists to prevent.
|
||||
over = usage_service.over_token_budget(db, owner)
|
||||
if over:
|
||||
generation.error = over
|
||||
return
|
||||
|
||||
# Read while the session is open: everything below outlives it.
|
||||
# Resolved once, so that what the loop is allowed to *run* is the
|
||||
# same set the endpoint was *offered* -- not whatever happens to
|
||||
@@ -520,6 +547,10 @@ async def _run(generation: Generation) -> None:
|
||||
# vision model, a plain string to anything else, or the endpoint
|
||||
# rejects the whole request.
|
||||
vision = chat_service.model_supports(db, chat, "vision")
|
||||
# Resolved while the session is open, like everything else here.
|
||||
# Empty for an admin and for a user in no group, which is every
|
||||
# instance that has not set one -- see permissions.limits_for.
|
||||
quota = permissions.limits_for(db, owner)
|
||||
chat_rounds = settings_store.chat_rounds(db)
|
||||
# A helper's chat is bounded by its own number, not the instance's.
|
||||
# Only reached in an *ordinary* helper chat -- an agent one is sized
|
||||
@@ -532,6 +563,15 @@ async def _run(generation: Generation) -> None:
|
||||
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
|
||||
|
||||
limits = tool_context.agent.limits if tool_context.agent else None
|
||||
# A group's ceiling narrows the instance's, never widens it. `min` of
|
||||
# two numbers where zero means "no limit" cannot be written as `min`:
|
||||
# the zero would win and turn a group with no opinion into an unlimited
|
||||
# one, so the two are folded by `_narrower`.
|
||||
if limits is not None and quota.get("agent_seconds"):
|
||||
limits = replace(
|
||||
limits,
|
||||
wall_seconds=_narrower(limits.wall_seconds, quota["agent_seconds"]),
|
||||
)
|
||||
# A ceiling, not a schedule -- the loop below ends the moment a round
|
||||
# produces no tool calls, which is the model saying it is done. Zero
|
||||
# means an ordinary chat has no ceiling either; `steps` is already a
|
||||
@@ -2083,6 +2123,23 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
message.stopped = generation.stopped
|
||||
message.complete = True
|
||||
|
||||
# What this reply cost, against the account's month. Here because
|
||||
# this is the single writer and it runs for a reply that finished, a
|
||||
# reply that was stopped and a reply that errored alike -- an
|
||||
# endpoint charges for tokens it generated whether or not anybody
|
||||
# wanted them, and a quota that only counted happy paths is one a
|
||||
# Stop button can walk past. `metrics.from_generation` is the one
|
||||
# place the three figures are worked out, so this is the same
|
||||
# arithmetic the bubble shows.
|
||||
spent = metrics_service.from_generation(generation)
|
||||
usage_service.record(
|
||||
db,
|
||||
chat.user_id,
|
||||
prompt_tokens=spent.prompt_tokens,
|
||||
completion_tokens=spent.completion_tokens,
|
||||
images=len(generation.attachment_ids),
|
||||
)
|
||||
|
||||
if title and not chat.title_generated:
|
||||
chat.title = title
|
||||
chat.title_generated = True
|
||||
|
||||
@@ -403,6 +403,23 @@ async def _review(
|
||||
|
||||
|
||||
# --- The runner ----------------------------------------------------------------
|
||||
def _over_quota(context: ToolContext) -> str:
|
||||
"""Why this account may not draw another picture today, or "".
|
||||
|
||||
Its own session, opened and closed before anything else: this runs before a
|
||||
request that takes a minute, and holding a session across one is the trade
|
||||
every long call in this codebase already refuses.
|
||||
"""
|
||||
from lembas.db.models import User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import usage as usage_service
|
||||
|
||||
if not context.owner_id:
|
||||
return ""
|
||||
with session_scope() as db:
|
||||
return usage_service.over_image_budget(db, db.get(User, context.owner_id))
|
||||
|
||||
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Generate one image, review it if there is anybody to ask, and keep one."""
|
||||
from lembas.db.session import session_scope
|
||||
@@ -427,6 +444,13 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
{**event, "status": "error", "error": "No chat."},
|
||||
)
|
||||
|
||||
# Before a minute of somebody's GPU is spent. Its own quota because it is
|
||||
# its own cost: a picture is no tokens at all, so a token budget says
|
||||
# nothing about how many of them one account may make.
|
||||
over = _over_quota(context)
|
||||
if over:
|
||||
return ToolOutcome(over, {**event, "status": "error", "error": over})
|
||||
|
||||
values = context.image_config or {}
|
||||
config = config_of(context)
|
||||
if not config.configured:
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -33,21 +34,35 @@ SNIPPET_CHARS = 400
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
"""Every report this person owns.
|
||||
"""Every report this person owns or has been shared.
|
||||
|
||||
Takes no session because it builds a query rather than running one, and
|
||||
takes `None` to mean nobody so an unauthenticated caller gets an empty
|
||||
result instead of an exception -- the same shape `sharing.visible_to` has,
|
||||
so a later move to shared reports is a change of one line here.
|
||||
result instead of an exception.
|
||||
|
||||
It said "a later move to shared reports is a change of one line here", and
|
||||
it was: `sharing.visible_to` is that line. Every listing, search and detail
|
||||
page went through this already, which is what made the move safe.
|
||||
"""
|
||||
if user is None:
|
||||
return select(Report).where(Report.id.is_(None))
|
||||
return select(Report).where(Report.owner_id == user.id)
|
||||
return select(Report).where(sharing.visible_to(Report, user))
|
||||
|
||||
|
||||
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or user is None or report.owner_id != user.id:
|
||||
if report is None or not sharing.can_read(db, report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def owned(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
"""The same, but only when they own it.
|
||||
|
||||
Sharing grants **reading**, so deleting and marking-as-read are the owner's
|
||||
alone. Two functions rather than a flag, because a route that wants one and
|
||||
calls the other is a bug you can see in the name.
|
||||
"""
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or not sharing.can_write(report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
@@ -202,6 +217,10 @@ def mark_read(db: DBSession, report: Report) -> Report:
|
||||
|
||||
|
||||
def delete(db: DBSession, report: Report) -> None:
|
||||
# Shares carry no foreign key to their resource, so nothing cascades and
|
||||
# this has to be said. A grant left behind names a report that has gone --
|
||||
# harmless now and a grant to whoever next holds that id later.
|
||||
sharing.forget_resource(db, report)
|
||||
db.delete(report)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Who may see a document, a note or a skill.
|
||||
"""Who may see a knowledge base, a note, a skill or a report.
|
||||
|
||||
One rule, in one place, for all three: you can see a resource if you own it, if
|
||||
One rule, in one place, for all four: you can see a resource if you own it, if
|
||||
it was shared with you by name, or if it was shared with a group you are in.
|
||||
|
||||
Documents are deliberately absent from that list. They are shared through the
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, delete, or_, select
|
||||
from sqlalchemy import ColumnElement, and_, delete, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
@@ -33,9 +33,11 @@ from lembas.db.models import (
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_REPORT,
|
||||
RESOURCE_SKILL,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Report,
|
||||
Share,
|
||||
Skill,
|
||||
User,
|
||||
@@ -49,6 +51,12 @@ RESOURCE_TYPES: dict[Any, str] = {
|
||||
KnowledgeBase: RESOURCE_BASE,
|
||||
Note: RESOURCE_NOTE,
|
||||
Skill: RESOURCE_SKILL,
|
||||
# A report joins the list and a memory still does not. A finished piece of
|
||||
# work is the thing somebody most wants to hand over -- "here is what the
|
||||
# Monday run found" -- and a report is read once and never answered, so
|
||||
# sharing it has none of the two-editors problem that keeps writing off the
|
||||
# table everywhere else here.
|
||||
Report: RESOURCE_REPORT,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +96,19 @@ def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
return or_(model.owner_id == user.id, model.id.in_(shared))
|
||||
|
||||
|
||||
def only_shared(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
"""Rows this user may see and does **not** own.
|
||||
|
||||
The "Shared with me" filter. Worth having as its own listing rather than a
|
||||
badge in the mixed one: a badge answers "is this mine?" for a row already on
|
||||
screen, and the question somebody actually has is "what have people given
|
||||
me?", which a mixed list of two hundred cannot answer at all.
|
||||
"""
|
||||
if user is None:
|
||||
return model.id.is_(None)
|
||||
return and_(visible_to(model, user), model.owner_id != user.id)
|
||||
|
||||
|
||||
def owned_by(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
"""Rows this user may *change*.
|
||||
|
||||
@@ -197,12 +218,39 @@ def forget_principal(db: DBSession, principal_type: str, principal_id: str) -> i
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def forget_owner(db: DBSession, owner_id: str) -> int:
|
||||
"""Drop every share of everything a departing account owned.
|
||||
|
||||
Their rows cascade when the account goes; the shares of those rows do not,
|
||||
because `Share.resource_id` has no foreign key to point at. Left behind,
|
||||
they are grants naming resources that no longer exist -- harmless today,
|
||||
and a grant to whoever next receives one of those ids if a future store
|
||||
ever reuses them.
|
||||
|
||||
Called *before* the delete, while the rows are still there to be found.
|
||||
`forget_principal` is the other half and covers shares pointing *at* them.
|
||||
"""
|
||||
removed = 0
|
||||
for model in RESOURCE_TYPES:
|
||||
owned = select(model.id).where(model.owner_id == owner_id)
|
||||
result = db.execute(
|
||||
delete(Share).where(
|
||||
Share.resource_type == RESOURCE_TYPES[model],
|
||||
Share.resource_id.in_(owned),
|
||||
)
|
||||
)
|
||||
removed += result.rowcount or 0
|
||||
return removed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"can_read",
|
||||
"can_write",
|
||||
"forget_owner",
|
||||
"forget_principal",
|
||||
"forget_resource",
|
||||
"grants_for",
|
||||
"only_shared",
|
||||
"owned_by",
|
||||
"resource_type",
|
||||
"set_grants",
|
||||
|
||||
@@ -76,6 +76,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
@@ -443,6 +444,12 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
|
||||
# rather than told it has run out of helpers, and the counter should
|
||||
# only move for a call that is about to spend one.
|
||||
values = settings_store.subagents(db)
|
||||
# A group's ceiling narrows the instance's, never widens it. Zero on
|
||||
# either side means "no opinion", so the two cannot be folded with
|
||||
# `min` -- see generation._narrower for the same arithmetic.
|
||||
allowance = permissions.limit(db, owner, "helpers_per_reply")
|
||||
if allowance:
|
||||
values = {**values, "max_per_reply": min(int(values["max_per_reply"]), allowance)}
|
||||
refusal = _budget(generation_service.running_for(parent_id), values)
|
||||
if refusal:
|
||||
return _error(refusal, task=task)
|
||||
|
||||
@@ -1604,6 +1604,21 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
# instrument -- `git log` is a read whatever its risk class says.
|
||||
writes_off = scoped_writes_off(chat)
|
||||
|
||||
# Reading and writing, split for the three gates where the two are genuinely
|
||||
# different decisions. A second check keyed on the tool's **risk**, applied
|
||||
# after the gate rather than instead of it -- so it can only ever narrow
|
||||
# what `_family_allowed` already allowed, and an instance that has never
|
||||
# looked at it behaves exactly as it did, all three defaulting on.
|
||||
#
|
||||
# Here rather than in `_family_allowed` because that one is given a family
|
||||
# and this needs the tool: the whole point is that two tools in one family
|
||||
# get different answers.
|
||||
def may_write(tool: ToolDef) -> bool:
|
||||
gate = gate_of(tool.family)
|
||||
if tool.risk != RISK_WRITE or gate not in permissions.SPLIT_GATES:
|
||||
return True
|
||||
return bool(allowed.get(f"tools.{gate}.write", True))
|
||||
|
||||
return ToolSet(
|
||||
tuple(
|
||||
tool
|
||||
@@ -1619,6 +1634,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
)
|
||||
and gate_of(tool.family) not in off
|
||||
and not (writes_off and tool.risk == RISK_WRITE)
|
||||
and may_write(tool)
|
||||
# Nothing to read and nothing to improve. Offering `skill_get` with
|
||||
# no skills is what makes a model spend a round looking one up and
|
||||
# being told it does not exist -- and `context.skills` already
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""What an account has spent, and whether it may spend more.
|
||||
|
||||
A row per user per period, not per reply. A per-reply ledger is what somebody
|
||||
eventually wants for a bill; this exists to answer one question on the request
|
||||
path — "has this account used its month?" — and that wants one indexed lookup
|
||||
rather than a sum over ten thousand rows.
|
||||
|
||||
**Recorded even when the reply failed.** `generation._persist` is the single
|
||||
writer for everything a reply produced, and it calls this whether the reply
|
||||
finished, was stopped or errored: an endpoint charges for tokens it generated
|
||||
regardless of whether anybody wanted them, and a quota that only counted happy
|
||||
paths would be one somebody could avoid by pressing Stop.
|
||||
|
||||
**Never raises.** A quota that broke a reply because its own bookkeeping failed
|
||||
would be worse than no quota. Everything here is best-effort and logs.
|
||||
|
||||
The period is UTC and the boundary is not the reader's midnight. A quota that
|
||||
reset at a different instant for each member of a group is one nobody can reason
|
||||
about, and nobody experiences a monthly allowance to the hour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import Attachment, Usage, User
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def period_of(moment: datetime | None = None) -> str:
|
||||
return (moment or datetime.now(tz=UTC)).astimezone(UTC).strftime("%Y-%m")
|
||||
|
||||
|
||||
def row_for(db: DBSession, user_id: str, *, period: str = "") -> Usage:
|
||||
"""This account's row for a period, made if it is not there yet."""
|
||||
period = period or period_of()
|
||||
row = db.scalar(select(Usage).where(Usage.user_id == user_id, Usage.period == period))
|
||||
if row is None:
|
||||
row = Usage(user_id=user_id, period=period)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def record(
|
||||
db: DBSession,
|
||||
user_id: str,
|
||||
*,
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
images: int = 0,
|
||||
replies: int = 1,
|
||||
) -> None:
|
||||
"""Add what one reply spent. Best-effort, and never raises."""
|
||||
if not user_id:
|
||||
return
|
||||
try:
|
||||
row = row_for(db, user_id)
|
||||
row.prompt_tokens += max(0, int(prompt_tokens))
|
||||
row.completion_tokens += max(0, int(completion_tokens))
|
||||
row.images += max(0, int(images))
|
||||
row.replies += max(0, int(replies))
|
||||
except Exception: # noqa: BLE001 - bookkeeping must never break a reply
|
||||
log.debug("could not record usage for %s", user_id, exc_info=True)
|
||||
|
||||
|
||||
def month_tokens(db: DBSession, user_id: str) -> int:
|
||||
row = db.scalar(select(Usage).where(Usage.user_id == user_id, Usage.period == period_of()))
|
||||
return int((row.prompt_tokens if row else 0) + (row.completion_tokens if row else 0))
|
||||
|
||||
|
||||
def images_today(db: DBSession, user_id: str) -> int:
|
||||
"""Pictures this account has made since midnight UTC.
|
||||
|
||||
Counted off `Attachment` rather than kept as a counter, because there is a
|
||||
natural source of truth and a *daily* counter would need a second row shape
|
||||
and a second reset. The month's total on `Usage.images` is for the admin
|
||||
screen, where a number that is a day stale costs nothing.
|
||||
"""
|
||||
start = datetime.combine(date.today(), datetime.min.time(), tzinfo=UTC) # noqa: DTZ011
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Attachment)
|
||||
.where(
|
||||
Attachment.user_id == user_id,
|
||||
Attachment.kind == "image",
|
||||
Attachment.created_at >= start,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
# --- Asking permission ----------------------------------------------------------
|
||||
def over_token_budget(db: DBSession, user: User | None) -> str:
|
||||
"""Why this account may not start another reply, or "".
|
||||
|
||||
Checked **before** a reply is built rather than while it streams. A quota
|
||||
that stopped a reply half way through would leave the reader with an answer
|
||||
that trails off, which is exactly what `_wrap_up` exists to prevent for every
|
||||
other budget here -- and unlike those, this one is knowable in advance.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
if user is None:
|
||||
return ""
|
||||
ceiling = permissions.limit(db, user, "monthly_tokens")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
spent = month_tokens(db, user.id)
|
||||
if spent < ceiling:
|
||||
return ""
|
||||
return (
|
||||
f"This account has used its {ceiling:,} tokens for the month "
|
||||
f"({spent:,} so far). It will reset on the first."
|
||||
)
|
||||
|
||||
|
||||
def over_image_budget(db: DBSession, user: User | None) -> str:
|
||||
from lembas.security import permissions
|
||||
|
||||
if user is None:
|
||||
return ""
|
||||
ceiling = permissions.limit(db, user, "images_per_day")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
made = images_today(db, user.id)
|
||||
if made < ceiling:
|
||||
return ""
|
||||
return f"This account has made its {ceiling} image(s) for today."
|
||||
|
||||
|
||||
def summary(db: DBSession, user: User) -> dict[str, int]:
|
||||
"""This month's figures, for the admin screen."""
|
||||
row = db.scalar(select(Usage).where(Usage.user_id == user.id, Usage.period == period_of()))
|
||||
return {
|
||||
"prompt_tokens": int(row.prompt_tokens if row else 0),
|
||||
"completion_tokens": int(row.completion_tokens if row else 0),
|
||||
"tokens": month_tokens(db, user.id),
|
||||
"replies": int(row.replies if row else 0),
|
||||
"images": int(row.images if row else 0),
|
||||
"images_today": images_today(db, user.id),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"images_today",
|
||||
"month_tokens",
|
||||
"over_image_budget",
|
||||
"over_token_budget",
|
||||
"period_of",
|
||||
"record",
|
||||
"row_for",
|
||||
"summary",
|
||||
]
|
||||
Reference in New Issue
Block a user