Skip to content

feat(chat): per-user daily token budget for non-admin users (#62) - #63

Merged
gangtao merged 7 commits into
mainfrom
feat/daily-token-budget
Aug 18, 2026
Merged

feat(chat): per-user daily token budget for non-admin users (#62)#63
gangtao merged 7 commits into
mainfrom
feat/daily-token-budget

Conversation

@gangtao

@gangtao gangtao commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Implements the token-budget half of #62 (question cap deferred, per your call).

What

Operators can cap daily LLM token consumption per non-admin user. admin is exempt. The limit is per-role (daily_token_limit), with a global fallback via config.

How

  • Metering — the /chat handler sums usage_metadata tokens across every model call in a turn (the tool loop makes several), with a response_metadata fallback for gateways that only pass the raw shape.
  • Storage — an append-only chat_usage stream (ts, username, tokens), written independent of TPK_CHAT_AUDIT so the budget can't be silently disabled by turning auditing off. Reads fail open (an unreachable store never wrongly blocks a user); writes are best-effort.
  • Limitskg_roles.daily_token_limit (uint32, _add_column_if_missing migration; existing roles → 0 = unlimited). Effective limit = the role's own, else the global TPK_DAILY_TOKEN_LIMIT / [server].daily_token_limit (0 = unlimited).
  • Enforcement — before running the agent, a non-admin at/over budget gets HTTP 429 with a friendly message + reset time (next UTC midnight). Enforcement is inherently next-turn (a turn's cost is only known once it runs), so the crossing turn completes and the next is blocked.
  • UI — role editor gains a "Daily token budget per user" field (add + edit); the chat surfaces the 429 with the server's reset message instead of a bare HTTP code.

Enforcement/metering are only active when a usage store is present (built in the production path), so existing unit tests that inject a fake agent are untouched and never hit a DB.

Caveat (documented in the issue)

Some gateways omit usage_metadata — those turns count as 0 tokens (free). A token budget is only as good as the meter; verify against your gateway.

Tests / verification

  • 118 unit tests pass (+11 new): usage helpers + fail-open, config precedence, enforcement (over / under / admin-bypass / no-store), metering.
  • Backend round-trips (role limit, daily per-user count) added for both backends.
  • Verified end-to-end on live timeplusd: schema, role-limit round-trip, and per-user daily counting.
  • web build + check:sanitize green.

🤖 Generated with Claude Code

gangtao and others added 3 commits August 17, 2026 15:49
The k8s manifests (#56/#57) predate the change that moved all tpk streams
under a dedicated `tpk` database (#59). For app-only — which connects to an
externally-managed Timeplus Enterprise — the app now runs
`CREATE DATABASE IF NOT EXISTS tpk` on startup, so the pre-existing DB user
needs CREATE DATABASE (first run) plus read/write on that database. A
restricted user would otherwise fail at startup/ingest.

- app-only.yaml: header now states the app creates its own database on the
  existing server; add a `TIMEPLUS_DATABASE` env (default `tpk`) with the
  privilege note and the pre-create fallback for restricted users.
- deploy/k8s/README.md: add a "tpk database" note to the App-only section
  (CREATE DATABASE grant + pre-create SQL), and a TIMEPLUS_DATABASE row to the
  Configuration table. Notes that enterprise/allinone are unaffected (they
  provision a full-privilege tpk user).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
Operators can cap daily LLM token consumption per non-admin user so a single
user can't run up unbounded cost against the shared agent. admin (the reserved
super-role) is exempt. (Token budget only; the optional question cap from #62 is
deferred.)

- usage.py: append-only `chat_usage` stream (ts, username, tokens) written per
  turn independent of TPK_CHAT_AUDIT, plus DbUsage (used_today/record). Reads
  fail OPEN (unreachable store never blocks a user); writes are best-effort.
- db: create chat_usage in ensure_schema (+ drop_schema); add
  kg_roles.daily_token_limit (uint32) with the same _add_column_if_missing
  lazy migration as capabilities (existing roles -> 0 = unlimited).
- auth.Role + kg_roles carry daily_token_limit; api /roles create/update/list
  carry it (validated >= 0).
- config.daily_token_limit(): global fallback (env TPK_DAILY_TOKEN_LIMIT >
  [server].daily_token_limit > 0) applied when a role sets no limit of its own.
- server /chat: sum usage_metadata tokens across the turn's model calls; before
  running the agent, block a non-admin who is at/over budget with HTTP 429 +
  reset time (next UTC midnight). Enforcement is next-turn (a turn's cost is
  only known once it runs); the crossing turn completes. Metering + enforcement
  only active when a usage store is present (production), so unit tests that
  inject a fake agent are untouched.
- web: role editor gains a "Daily token budget per user" field (add + edit);
  chat surfaces the 429 with the server's friendly reset message.
- tests: usage helpers + fail-open, config precedence, enforcement (over/under/
  admin-bypass/no-store), and backend round-trips (role limit, daily count).
  Verified end-to-end on live timeplusd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
Surface the budget proactively, not only on a 429. Adds GET /chat/usage
(chat-cap gated) returning {limited, used, limit, remaining, reset} for the
current user — limited:false for admins, unlimited roles, or when no budget is
enforced, so the UI shows no indicator. Extracts the effective-limit rule into
usage.effective_daily_limit (role's own limit, else the global fallback), reused
by /chat enforcement and the new endpoint.

Chat UI fetches /chat/usage on load and after each turn (a turn spends tokens;
the append-only read lags slightly so it refreshes again shortly after), and
shows "used / limit", tokens left, and the reset time in the header and empty
state for users who have a budget.

Tests: /chat/usage for a limited user, and unlimited for admin / no-store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
@gangtao

gangtao commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Added user-facing budget display (commit c05ca94): a new GET /chat/usage returns {limited, used, limit, remaining, reset} for the current user, and the chat UI shows used / limit, tokens left, and the reset time in the header and empty state — refreshed on load and after each turn. limited: false (admins / unlimited / no store) shows no indicator. 120 unit tests green; web build + sanitize pass.

gangtao and others added 3 commits August 17, 2026 16:35
The global daily-token-budget fallback was documented in .env.example but not
wired into the compose env, so setting it in .env had no effect. Pass it through
in both the DB+App and all-in-one files (a role's own daily_token_limit still
overrides it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
Adds a per-user daily token limit that overrides the role limit, so admins can
tune budgets for individual users, not just per role. Effective limit precedence
is now: user override > role limit > global fallback (0 = unlimited at each
level).

- db/auth: kg_users.daily_token_limit (uint32) with _add_column_if_missing
  migration (existing users -> 0 = inherit); User field + parse/upsert.
- usage.effective_daily_limit(user_limit, role, global) implements the
  precedence; /chat enforcement and /chat/usage both pass the user's override.
- api: AddUser/UpdateUser carry daily_token_limit (validated >= 0); /users list
  returns it; update leaves it unchanged when omitted.
- web Users tab: a "Daily tokens" column with a per-user number input (admins
  show "unlimited"), and a budget field in the Add-user modal.
- tests: precedence unit test, user-override enforcement + /chat/usage reflect
  it. Verified E2E on live timeplusd incl. the kg_users column migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
Change the built-in fallback from 0 (unlimited) to 500000 tokens/user/day, so
non-admin users are capped by default. A per-user or role daily_token_limit
still overrides it, and setting TPK_DAILY_TOKEN_LIMIT / [server].daily_token_limit
to 0 restores unlimited-by-default.

Updates the README matrix, repos.toml / repos.container.toml comments,
.env.example, and the config precedence test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
@gangtao

gangtao commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Code review

Found 2 issues:

  1. A role/user daily_token_limit of 0 means "inherit the next level down", but the role-level comments, the API error, and the role UI label all call it "unlimited". Since the global default is now 500000, setting a role to 0 silently caps it at 500000, not unlimited — the opposite of what the label says. effective_daily_limit's own docstring ("0 at every level means unlimited") contradicts its code, which falls through role 0 to the global default. The per-user field is already labeled correctly ("0 = inherit role/global"); the role level and the shared docstring should match.

def effective_daily_limit(user_limit: int, role, global_default: int) -> int:
"""The daily token budget that applies to a user, by precedence (#62):
the user's own override, else their role's limit, else the global fallback.
0 at every level means unlimited. `role` may be a Role or None (an
unreadable/absent role falls through to the global)."""
ul = int(user_limit or 0)
if ul > 0:
return ul
role_limit = getattr(role, "daily_token_limit", 0) or 0
return int(role_limit) if role_limit > 0 else max(int(global_default), 0)

capabilities: list[str] = field(default_factory=lambda: list(DEFAULT_CAPABILITIES))
# Daily per-user token budget for members of this role (0 = unlimited). See
# #62; a global fallback (config.daily_token_limit) applies when this is 0.
daily_token_limit: int = 0

  1. daily_token_limit bypasses the bounded-delegation guard. _guard_grant bounds only capabilities and entry_keys against the actor's own grant, so a non-admin users:manage delegate can set any manageable user's or role's daily_token_limit to 0/inherit or an arbitrarily large value — handing out unlimited LLM spend through the very endpoints feat: role-based function capabilities across API + UI (closes #23) #24 hardened against privilege escalation. Recommend restricting daily_token_limit changes to admin.

existing = auth_mod.get_role(client, body.name, prefix=prefix)
if existing is not None:
_guard_grant(client, actor, existing.capabilities, existing.entry_keys)
_guard_grant(client, actor, caps, body.entry_keys)
auth_mod.upsert_role(client, auth_mod.Role(
body.name, body.entry_keys, body.description, caps,
daily_token_limit=body.daily_token_limit), prefix=prefix)
return {"ok": True}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

#63)

Code review of #63 found two issues:

1. Role/user daily_token_limit of 0 means "inherit the next level down", not
   "unlimited" (only a 0 that reaches the global fallback is unlimited). The
   role-level comments, API error, effective_daily_limit docstring, and role UI
   labels wrongly said "0 = unlimited" — which, now that the global default is
   500000, would mislead an admin into thinking a role is uncapped when it's
   capped at 500000. Relabel role level to "0 = inherit global" everywhere;
   the /chat/usage docstring's "degrades to unlimited" corrected to "global
   default". (The per-user field was already labeled correctly.)

2. daily_token_limit bypassed the bounded-delegation guard: a non-admin
   users:manage delegate could set any manageable user's/role's budget to an
   arbitrary value, handing out unlimited LLM spend. Add _guard_token_limit —
   only an admin may change a budget; a delegate may still manage users/roles
   as long as it leaves the budget unchanged (403 otherwise).

Tests: role-0-inherits-global precedence; manager-cannot-set-budget (verified
against live timeplusd). Full suite green; web build + sanitize pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPzYZXxyTdj5G25KoujpHb
@gangtao

gangtao commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Both issues fixed in b3d45ef:

  1. Relabeled role/user daily_token_limit of 0 as "inherit" (not "unlimited") across the comments, API error, effective_daily_limit docstring, and role UI labels; corrected the /chat/usage docstring. Added a precedence test that a role limit of 0 inherits the global default.
  2. Added _guard_token_limit — only an admin may change a per-user/role token budget; a non-admin users:manage delegate can still manage users/roles as long as the budget is unchanged (403 otherwise). New test_manager_cannot_set_token_budget, verified against live timeplusd.

Full suite green; web build + sanitize pass.

@gangtao
gangtao merged commit 07f4158 into main Aug 18, 2026
2 checks passed
@gangtao
gangtao deleted the feat/daily-token-budget branch August 18, 2026 01:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant