[Feat]: Per-LLM-call credit quota with admin UI (issue #15)#35
Conversation
Tracks every LLM call against per-role rolling-window caps and surfaces admin controls for providers, keys, models, and credit history. Provider schema: - Move `baseUrl` from `provider.apiKeys[]` to a top-level required column on `provider` (a provider's endpoint is a property of the upstream, not of the auth material). Callback metadata → `findProviderId()` matches this to recover providerId without scanning jsonb. - Admin UI rewires baseUrl to a dedicated editable row on each provider card; keys table drops its baseUrl column. Credit accounting (`lib/credit/`): - `CreditTrackingHandler` (BaseCallbackHandler) caches RunMeta by runId in `handleLLMStart` (langgraph_node, ls_model_name, llm.kwargs .configuration.baseURL) and looks it up in `handleLLMEnd` / `handleLLMError` — handleLLMEnd doesn't reliably get metadata in real runtime, so the map is the only way to carry userId across hooks. - handleLLMEnd records success rows (credits = input/1k * inputPer1k + output/1k * outputPer1k, frozen at call time per rate-change semantics). handleLLMError records error rows with zero credits. - handleLLMStart does NOT enforce quota — LangChain's CallbackManager swallows throws there, so enforcement moves to `backend/model.ts`'s quota-aware wrapper (separate PR) that throws before invoke/stream. Admin UI (`app/admin/`): - Providers tab: per-provider cards with editable baseUrl, models table, keys table (encrypted on POST, only `name` tail exposed on GET). - Roles tab: edit creditLimit + windowHours per role. - Settings → Credits tab: per-user call history with status badge. Migration (`db/migrations/0003_melted_bedlam.sql`): - Seeds `role` rows (guest/user/admin) before the FK on user.role_id. - Seeds the MVP openai provider with env-driven fields via `__VAR__` placeholders expanded by `scripts/db-migrate.ts` and `tests/setup.ts`: - `__OPENAI_BASE_URL__` → env or https://api.openai.com/v1 - `__OPENAI_API_KEY_ENCRYPTED__` → AES-256-GCM blob jsonb literal (empty array when env unset) - `__OPENAI_MODEL_JSON__` → model entry jsonb literal with inputPer1k=0.25, outputPer1k=1 - `__SEED_PROVIDER_ID__` → 'openai' (or OPENAI_SEED_PROVIDER_ID env) Observability: - Refactor: move `backend/observability/callback-collector.ts` → `lib/observability/callback.ts` (shared between backend and tests). Tests: 835/835 passing (lib/credit/callback, lib/provider/schema, api/admin/providers, observability suite). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename generic "model" / "tools" nodes to "chatModel" / "chatTools", "codeModel" / "codeTools", etc. inside each subgraph. The parent agent graph embeds these subgraphs by node name, so identical names collide when two subgraphs are wired in (e.g. both declaring a "tools" node). Per-agent prefix keeps each subgraph's internal topology namespaced. Each `*Route` helper stays inline at the subgraph file — it's only used by `addConditionalEdges`, no need to export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate every POST/PUT/PATCH at app/api/[..._path] against checkQuota(userId); when over cap, return a fabricated LangGraph SSE stream that emits a show_quota_card tool_call so the chat surface renders the QuotaCard instead of a 4xx. Quota window is a fixed UTC day (date_trunc('day', now() AT TIME ZONE 'UTC')) so every user shares one resetAt, not a per-user rolling drift. Surface the same numbers as a read-only block inside the UserButton dropdown — single shared <QuotaProgress /> primitive drives both the slot and the card so they can't disagree. roleName is stamped into window.__CONFIG__ by app/layout.tsx (server-side JOIN) so UserView reads it without a fetch or prop drilling. Admins get a one-click Admin Dashboard menu in the dropdown. Tool-UI show_quota_card is client-only — no backend tool, the proxy injects the tool_call directly on the messages/partial event so prior turn history is preserved.
Drop the duplicate CoinsIcon + Usage header inside the card variant — CardHeader above already carries the icon and Credit limit reached title. Shift to pl-12 so the bar and copy align under that header. Swap SparklesIcon → CoinsIcon to match the slot. Tweak copy to 'Reached Nh window' in both branches for parity.
…dit/status.ts The UserButton dropdown slot and the settings page summary card both read the same /api/credit/status endpoint. Hoisting the module-scope cache + in-flight promise into a dedicated lib/credit/status.ts keeps the two surfaces in lock-step and lets future readers join without duplicating the dedupe logic. The 1s TTL is preserved so opening the menu after a pause still fetches fresh data.
…imestamps Adds a standalone Window Quota section above the call history: 4 compact stat cards (Used / Remaining / Used % / Window) + a thin progress bar, then a separate Call history section. Reuses lib/credit/status.ts so opening the settings tab while the UserButton menu is mounted costs zero extra fetches. Swaps the When column to absolute YYYY-MM-DD HH:mm:ss timestamps for record display, and renames Agent -> LLM.
…verlaps the trigger When the trigger sits at the bottom of the screen, the menu opens upward and the LAST item (e.g. Sign Out) ends up directly under the trigger. The pointerdown opens the menu; the same physical press continues, and the click event fires on that overlapping item, instantly triggering it. Track pointer movement on the content; if the pointer has not moved since the menu mounted, the first pointerdown is the same press that opened the menu, so preventDefault it. Real clicks after the user moves the mouse pass through untouched.
Switches the displayed apiKey identifier from '...xyz9' (tail-only) to 'sk-…xyz9' (first 3 + ellipsis + last 4) so operators recognise the key family (sk-, gsk-, AIza…) at a glance and still confirm the tail. No plaintext exposure: the secret body stays server-side. Tests + public docs (ADMIN.md, APIS.md, DB.md) follow per CLAUDE.md rule #1.
…+ inline add rows Bring /admin tables to a single visual language: - Sentence-case column headers + button labels (Edit/Delete/Save/Cancel/Rotate), matching the memory tab - Replaced native confirm() with Radix Dialog (destructive variant reserved for the in-modal confirm only; row actions use outline) - Converted the standalone 'Add role' Card into an inline trailing row in the Roles table, matching the inline add row pattern in Models + Keys - Compacted inline add row inputs (h-7 -> h-5, smaller text, no row tint) so the data rows above stay visually dominant - 'Add provider' card restyled: dashed border + muted background + Plus icon on Create, drops the CardHeader chrome so the data cards read as primary - page.tsx: removed the full-bleed background wrapper so /admin renders in the same shell as the rest of the app
7484a01 used onPointerDown, which fires on the next press — not the release of the press that just opened the menu. Switch to onPointerUp so we catch the opener's release directly. Per the Pointer Events spec, preventDefault on pointerup suppresses the compatibility click that would otherwise tunnel through to the item sitting under the trigger. hasMovedRef still gates it: a real click after the user moves the mouse passes through untouched.
Mirrors the slot variant in components/auth/user/credit-usage-slot.tsx, where the icon already has text-muted-foreground. Without it the unlimited state rendered the icon at full foreground color while the surrounding text used muted-foreground, making the icon look louder than the label.
peekCachedStatus() returning null used to short-circuit to null, so the slot disappeared during the first fetch and then popped in when status landed — visible layout shift in the dropdown. Render a Skeleton that mirrors the QuotaProgress slot layout (header / used / progress+pct / window hint) so the slot's height stays constant. Cache hits skip it: peekCachedStatus returns non-null on the first render and we go straight to QuotaProgress. Also tighten the wrapper padding to py-1.5 to match the slot's tighter chrome.
Two rounded rectangles for icon + label read like a tiny progress bar; one wider pill (h-4 w-16, ~icon+gap+Usage width) reads as the header placeholder and matches the rendered bar height. mb-1.5 preserves the original pb-1.5 spacing before the Used row.
Pulsing the icon and "Usage" label felt noisy — they're constant chrome that never changes between users / states. Only the data rows (used/total, progress+pct, window hint) need to be placeholders. Also bump gap-1→gap-2 for breathing room and widen the bar / used / hint placeholders so the layout doesn't visibly contract when status lands.
Three call sites rendered the same CoinsIcon + "Usage" label markup: <SlotSkeleton />'s header, the unlimited-state branch of <CreditUsageSlot />, and the "slot" variant of <QuotaProgress />. Centralised in components/credit/quota-header.tsx so the icon, label, and gap/text sizing stay in lock-step. className is forwarded so callers can layer outer spacing (QuotaProgress passes pb-1.5 for extra breathing room before the "Used X / Y" line; the other two callers use the surrounding flex-col gap for spacing).
Wrap the unlimited + limited branches in <DropdownMenuItem asChild onSelect={...}>. asChild merges Radix's onSelect (and its auto-close) onto the slot's own div so hover/focus styling lands on the content instead of an extra ring around it. A plain button would leave the menu open over the new page — Radix only auto-closes on DropdownMenuItem.onSelect, not on arbitrary child clicks. Skeleton stays unwrapped: there's nothing to navigate to while loading. hover:bg-accent/40 + focus:bg-accent matches the other menu items' affordance, with a lighter hover so it reads as preview not selected.
…end lib
Three words were doing the same job: Usage (slot + QuotaProgress header), Quota (lib functions, component names, prop names, tool name, message-id prefixes), and Credit (unit, tab label, summary section). Unify on Credit as the canonical term:\n\n- components/credit/quota-{header,progress}.tsx → credit-{header,progress}.tsx; QuotaHeader/QuotaProgress/QuotaTier/tierFor/tierClass/QuotaProgressProps → Credit*; CreditHeader now renders 'Credits' instead of 'Usage'\n- components/tool-ui/quota/ → credit/; QuotaCard → CreditCard; tool name show_quota_card → show_credit_card\n- components/auth/user/user-button.tsx: quotaSlot prop → creditSlot; app/assistant.tsx call sites updated\n- lib/credit/check.ts: checkQuota → checkCredit, QuotaStatus → CreditStatus\n- lib/credit/errors.ts: QuotaExceededError → CreditExceededError (class name + this.name + message)\n- lib/credit/invoke.ts: quotaExceededReply → creditExceededReply\n- lib/credit/callback.ts: instanceof check + comment\n- app/api/[..._path]/route.ts: import + function name creditBlockedResponse + local var + tool name + runId/messageId prefixes + additional_kwargs flag\n- app/api/credit/status/route.ts: import + call\n- tests/lib/credit/{check,callback}.test.ts: imports, calls, runId string literals\n- components/credit/credit-summary-card.tsx: section heading 'Window Quota' → 'Window credits'; helper imports
docs/CREDIT.md: heading 'Credit quota' → 'Credit'; checkQuota → checkCredit; QuotaExceededError → CreditExceededError; 'invokeWithQuota' → 'invokeWithCredit'; 'quota check' → 'credit check'; 'quota-exceeded throw' → 'credit-exceeded throw'\n\ndocs/ADMIN.md: 'credit quota enforcement' → 'credit enforcement'; checkQuota → checkCredit\n\ndocs/DB.md: checkQuota → checkCredit; QuotaExceededError → CreditExceededError in the role-table notes + the credit_usage_log row\n\ndocs/AUTH.md: 'credit quota' → 'credit cap'; 'env-quota knob' → 'env-knob for per-user credit'; 'Resend free-tier quota exceeded' → 'Resend free-tier rate limit exceeded' (different concept — email provider rate limit, not our credit system)\n\nUnrelated 'quota' mentions left alone: docs/ATTACHMENTS.md 'storage quotas' (R2 storage, not credit), docs/DEPLOY.md 'project id quota' (WalletConnect project quota, third-party API), lib/email/send-verification.ts EMAIL_QUOTA_EXCEEDED code (third-party email rate limit, code is wire-shaped so we don't rename it).
The constant EMAIL_QUOTA_EXCEEDED is wire-shaped (clients may pattern-match on it) so it stays — only the inline comment gets clarified to point at the right concept: Resend's free-tier rate limit, not the user credit system.
windowHours was read but the SQL predicate hardcoded (UTC midnight) and resetAt was always nextUtcMidnight(). Setting windowHours=8 in admin made the displayed 'resets at HH:MM' drift to whatever UTC midnight happens to be in the user's local zone — the SQL was effectively summing a 24h slice regardless of the configured window.\n\nFix: compute windowStart as the UTC-aligned floor of now into a windowHours bucket (epoch=0 lands on UTC midnight, so 8h buckets land on 00:00 / 08:00 / 16:00; 24h reverts to UTC midnight). Pass windowStart as the SQL predicate; resetAt = windowStart + windowHours. Display still localizes via toLocaleTimeString. For a UTC+8 user on windowHours=8, the slot now shows 'Reached 8h window · resets at 16:00' when now is in [08:00, 16:00) UTC — matches the cycle the admin configured.
node-postgres accepts Buffer | string for parameter binding; passing a Date instance triggers ERR_INVALID_ARG_TYPE. windowStart.toISOString() serializes to the same UTC timestamp Postgres parses back to the same timestamp without zone shift.
windowStart now floor-snapped to UTC multiples of windowHours (epoch=0 on UTC midnight, so windowHours=8 lands on 00/08/16 UTC). Display components already localize via toLocaleTimeString, so a UTC+8 user sees 00/08/16 UTC as 08/16/00 local — same moment, different wall-clock. Also drops Plus/Trash2 icon prefixes from admin destructive buttons (CLAUDE.md rule 7: tool-UI buttons are text-only) and rephrases the reset copy from 'Reached 8h window · resets at 00:00' to 'Reset every 8 hours, next reset at 00:00' so the line reads as a status, not a state-machine trigger.
…otate Replace inline row editing with FormDialog-driven flows for Providers, Models, Keys, and Roles. Auto-generate provider id on add; lock id="default" from deletion (server 409 + UI disabled). KeyDialog derives name from plaintext; explicit name still overrides. PATCH /api/admin/providers/[id]/keys/[keyName] now re-derives name from rotate plaintext unless caller overrides, with collision check excluding self. Adds Badge, FormDialog, UnderlineInput primitives.
Migration 0004 adds user.banned. lib/auth/config.ts session.create.before hook blocks signin for banned users. PATCH /api/admin/users/[id] DELETEs every session row when banned flips to true so the cutoff is immediate (last-admin guard mirrors default-provider protection). AdminTabs gains a Users tab with credit-summary-card-style StatCards (Total / Admins / Banned) and a table where Edit uses a Select dropdown for role. Skipped Better Auth's built-in admin plugin to avoid its parallel role text column colliding with our role_id FK.
Throwing APIError(FORBIDDEN, { message, code: "USER_BANNED" }) instead of a bare Error is the only path that survives Better Auth's error router — the typed shape is what reaches the client. Add a belt-and-suspenders toast.error() in the signin onError so the user always sees WHY signin failed (banned / wrong password / etc.); EMAIL_NOT_VERIFIED keeps its existing redirect.
lsof -ti :2024,:3000,:3001 parses the comma list as one arg and silently matches nothing — kill was a no-op, stale next-server survived every shutdown. Iterate per-port with -iTCP:$p -sTCP:LISTEN so we target only the listener PID and skip Chrome's ESTABLISHED connections to those ports.
…sent loadEnvConfig skips .env.local under NODE_ENV=test, so the KEK never reaches the test process even though .env still feeds OPENAI_API_KEY in. Previously that crashed every vitest run with KekMissingError before any test could load. Treat "no KEK" the same as "no key" — the seed INSERT becomes enabled=true with an empty api_keys array, harmless for the suite.
…gent Two problems fixed in one move: (1) backend/agent.ts and backend/background-agent.ts each instantiated their own CreditTrackingHandler — duplicate singleton. (2) background-agent.ts only registered capturingHandler, so LLM calls in the background graph never entered credit quota. Hoist the singleton into backend/callbacks.ts (next to capturingHandler) and have both graphs import it from there.
New lib/provider/model-registry.ts: getChatModel({providerId?, modelName?}) resolves a ChatOpenAI from the DB on first call, then reuses an in-process LRUCache (key=`${providerId}:${modelName}`, max=10, ttl=1h). apiKeys are picked at random and decrypted via loadKek + aesGcmDecrypt. invalidateModelCache() (with optional key) drops entries on demand. backend/model.ts re-exports both, keeps the legacy env-only chatModel const for the 7 createAgent({llm:chatModel}) consumers, and fires a fire-and-forget warmup so the LRU is primed before the first /chat request.
All 7 admin provider CUD handlers (POST/PATCH/DELETE on the provider row, on models, on keys) now call invalidateModelCache() after a successful mutation, so admin writes take effect immediately without waiting out the TTL.
.env.test gains a fake LLM_KEY_ENCRYPTION_KEY so model-registry tests can round-trip a real encrypted key through aesGcmDecrypt.
Top-level await in backend/model.ts resolves getChatModel() before the export is captured, so the 7 createAgent({llm: chatModel}) consumers now read the DB-configured provider/model on backend boot instead of the env-fallback one. DB unreachable still falls back to env (buildEnvModel). Type stays ChatOpenAI because BaseChatModel.bindTools is optional and would break every consumer's .bindTools(...).invoke(...) chain; today the registry hard-codes ChatOpenAI, so the cast on getChatModel() is safe — see the comment for the future-非-OpenAI boundary.
There was a problem hiding this comment.
All reported issues were addressed across 121 files
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
Two greptile findings from PR #35 review: 1. providerPatchSchema accepted a raw apiKeys[] on PATCH, which would write caller-supplied bytes straight into the jsonb and fail later at aesGcmDecrypt with no signal. Strip apiKeys from the PATCH schema — encrypted material must travel through the dedicated /keys sub-resource which routes through encryptApiKey. The admin UI never assembled apiKeys on PATCH (whole-array replacement goes through POST /keys), so this is a defense-in-depth tighten. 2. POST /api/admin/providers surfaced the unique-PK constraint as a generic 500. Map to 409 DUPLICATE via onConflictDoNothing + returning-empty detection so the admin UI's error toast can distinguish the conflict path from other failures.
|
Too many files changed for review. ( Bypass the limit by tagging |
Cubic findings from PR #35 review, real correctness only: 1. Unbanning the only admin (PATCH {banned:false} with no roleId) incorrectly returned 409 LAST_ADMIN because the guard condition was 'roleId !== "admin" || banned === true' — undefined !== "admin" evaluated true. Split the role and ban triggers so unban is unconditional. Added a regression test in tests/api/admin/users.test.ts. 2-4. Doc drift only: - POST /api/admin/providers body example was missing the REQUIRED baseUrl field (would 400 on the schema). - DELETE /api/admin/providers failure codes missing 409 PROTECTED for the default provider. - POST /api/admin/providers/[id]/keys doc advertised baseUrl as a per-key field; baseUrl lives on the provider row, the keys route only accepts {plaintext}.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Review — PR #35 (Per-LLM-call credit quota + admin UI)Solid foundation: 868 tests pass, the proxy-level credit gate + callback-only write path are clean, AES-256-GCM is correctly implemented, and docs are in lock-step with the new endpoints (rule #1, #9, #10). A few issues survived that I would like fixed before merge — one P1 and three P2s. None are happy-path blockers; all are triggered by realistic inputs or concurrency. P1 — crash turns every blocked-user request into a 500
P2 — concurrency
P3 — observability
Already fixed in the current diff (not current issues)
CLAUDE.md compliance
Posting inline comments on the three concrete locations. |
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
|
Reviewed the PR end-to-end (121 files, +10,100/-361). The credit gate, provider registry, and admin UI are well-shaped — clean separation of concerns, the proxy short-circuits before LangGraph (so blocked turns do not burn tokens), the singleton callback wired into both graphs, AES-256-GCM with random IV + auth tag, role-gate overload on withAuth, immediate ban enforcement via session DELETE + databaseHooks.session.create.before, and a thoughtful seed order in migration 0003 that handles the FK default. Greptile initial flag about PATCH /api/admin/providers/[id] accepting raw apiKeys was already fixed in lib/credit/zod.ts (the providerNoDefaults schema intentionally omits the field) — good call to put the no-defaults contract in code rather than comment. Eight issues worth fixing before merge, ordered by severity:
Cross-checked docs/APIS.md, docs/ADMIN.md, docs/CREDIT.md, docs/AUTH.md, docs/PROVIDERS.md, docs/DB.md — they read consistent with the code (incl. the right apiKeys-stripped PATCH contract and the immediate-ban session DELETE). One note that ties to finding #6: docs/CREDIT.md lines 30 and 77 still describe agentName semantics that no longer match what the renamed inner node will produce. Inline comments attached at each site. |
CI lint caught the oxlint no-meaningless-void-operator rule on three onClick handlers in admin-tabs.tsx — confirmRemove already returns void, so the void operator was a no-op. Replaced () => void confirmRemove() with the bare reference. Ran oxfmt to normalize the rest of the diff.
| // | ||
| // Admin role: creditLimit IS NULL → unlimited, skip the SUM entirely. | ||
| export async function checkCredit(userId: string): Promise<CreditStatus> { | ||
| const [{ creditLimit, windowHours, roleName }] = await db |
There was a problem hiding this comment.
Hard crash on role FK mismatch
const [{ creditLimit, windowHours, roleName }] = await db.select(...) destructures index [0], but an innerJoin(user, role) returns [] whenever a user row exists with a roleId that no longer resolves to a row in role (the very scenario lib/auth/role-queries.ts:38-55 documents as "extremely rare, but defensive"). On that path the destructure becomes [{ ... }] = [] → TypeError: Cannot destructure property 'creditLimit' of 'undefined', which the proxy turns into an unhandled 500 for every blocked user (the proxy is the only caller of checkCredit today).
Minimal fix: replace the innerJoin with leftJoin and fall back to the literal { creditLimit: 0, windowHours: 24, name: "Unknown" } (matching the synthetic row role-queries.ts returns), or guard for length === 0 immediately after the query.
| const [{ creditLimit, windowHours, roleName }] = await db | |
| const rows = await db | |
| .select({ | |
| creditLimit: role.creditLimit, | |
| windowHours: role.windowHours, | |
| roleName: role.name, | |
| }) | |
| .from(user) | |
| .innerJoin(role, eq(user.roleId, role.id)) | |
| .where(eq(user.id, userId)); | |
| if (rows.length === 0) { | |
| // FK-corrupt: user exists but role row is missing. Apply the same | |
| // defensive fallback as lib/auth/role-queries.ts so the gate fails | |
| // closed (deny) instead of throwing TypeError through the proxy. | |
| return { | |
| allowed: false, | |
| used: 0, | |
| limit: 0, | |
| windowHours: 24, | |
| resetAt: new Date(), | |
| roleName: "Unknown", | |
| }; | |
| } | |
| const [{ creditLimit, windowHours, roleName }] = rows; |
No test covers this branch — please add one (rule #2).
| const roleChange = parsed.data.roleId !== undefined && parsed.data.roleId !== "admin"; | ||
| const newBan = parsed.data.banned === true; | ||
| if (existing.roleId === "admin" && (roleChange || newBan)) { | ||
| const [{ c }] = await db.select({ c: count() }).from(user).where(eq(user.roleId, "admin")); |
There was a problem hiding this comment.
TOCTOU race in last-admin guard — PATCH + DELETE
The guard performs a non-transactional read of count(*) then issues the UPDATE/DELETE in a separate round-trip. With exactly two admins and two simultaneous demotion requests (PATCH /users/adminA + PATCH /users/adminB), both requests see c === 2, both pass c <= 1, both apply — leaving 0 admins. The same race exists in the DELETE handler at lines 82-90.
Concrete failure scenario is easy to reach: two open admin tabs triggering the same flow on a slow Postgres, or any retry/backoff loop running both calls.
Fix: wrap count + mutate in db.transaction(async (tx) => { ... }) with SERIALIZABLE, or lock the row(s) being mutated with SELECT ... FOR UPDATE before the count. The transaction must also include the session DELETE on banned: true for the PATCH case so a concurrent ban can't slip a new session in.
Tests (tests/api/admin/users.test.ts:131-152) only cover the serial single-admin path — rule #2 asks for the concurrent scenario to be exercised.
| export async function getChatModel(opts: GetChatModelOpts = {}): Promise<ChatOpenAI> { | ||
| try { | ||
| return (await getChatModelFromDB(opts)) as ChatOpenAI; | ||
| } catch { |
There was a problem hiding this comment.
Bare catch {} swallows every DB-side failure
getChatModel is the canonical entry point (per the doc-comment on lines 28-38), but a bare catch {} masks every error from getChatModelFromDB — KekMissingError, Postgres going away, "no enabled provider", FK corruption in provider. The fallback to buildEnvModel() always runs, so a misconfigured production deploy with LLM_KEY_ENCRYPTION_KEY set but the DB registry as source-of-truth will silently switch to process.env.OPENAI_API_KEY with no log line. Billing then diverges because CreditTrackingHandler keeps writing to credit_usage_log against whichever key actually served the request, and an operator has no signal until something else breaks.
Minimal fix: re-throw non-recoverable errors (env fallback impossible, KekMissingError), and console.warn the swallowed ones so the operator has something.
| } catch { | |
| export async function getChatModel(opts: GetChatModelOpts = {}): Promise<ChatOpenAI> { | |
| try { | |
| return (await getChatModelFromDB(opts)) as ChatOpenAI; | |
| } catch (err) { | |
| if (err instanceof KekMissingError) { | |
| // Env fallback depends on OPENAI_API_KEY; surface so the deploy | |
| // doesn't silently regress to an untracked billing source. | |
| console.warn("provider registry unavailable:", err.message); | |
| } | |
| if (!process.env.OPENAI_API_KEY) throw err; | |
| return buildEnvModel(); | |
| } | |
| } |
Add a KekMissingError import too.
| baseUrl?: string | null; | ||
| modelName?: string; | ||
| }): Promise<string | null> { | ||
| const rows = await db.select().from(provider); |
There was a problem hiding this comment.
Two uncached DB queries per successful LLM call
findProviderId does db.select().from(provider) (line 75 — full table scan) and getModelRate does db.select().from(provider).where(eq(provider.id, providerId)) (line 56). Both run on every handleLLMEnd, and the LRU in lib/provider/model-registry.ts only covers getChatModelFromDB itself — it does NOT cover this callback path, so the credit-callback misses the cache entirely.
For a single-tenant MVP the provider table is tiny so this is not a real perf issue today, but the callback is the accounting-path hot loop (every model call hits it twice). Two cheap improvements: (1) cache a snapshot of provider[] keyed off invalidateModelCache() (the existing CUD invalidation already calls it from every admin route), and (2) make findProviderId+getModelRate share a single SELECT instead of two round-trips — the provider row is identical for both.
| const rows = await db.select().from(provider); | |
| export async function findProviderId(opts: { | |
| baseUrl?: string | null; | |
| modelName?: string; | |
| }): Promise<string | null> { | |
| const rows = await listProvidersCached(); // single shared snapshot | |
| if (opts.baseUrl) { | |
| for (const row of rows) { | |
| if (row.baseUrl === opts.baseUrl) return row.id; | |
| } | |
| } | |
| if (opts.modelName) { | |
| for (const row of rows) { | |
| if (row.models.some((m) => m.name === opts.modelName && m.enabled)) return row.id; | |
| } | |
| } | |
| return null; | |
| } |
listProvidersCached would live next to invalidateModelCache in lib/provider/model-registry.ts so admin writes drop the cache as today.
| * `handleLLMStart` does. Caching by runId is the only way to carry | ||
| * userId + agentName + baseURL across the three hooks. | ||
| * | ||
| * Credit enforcement is NOT this handler's job — LangChain's CallbackManager |
There was a problem hiding this comment.
Documented enforcement site doesn't exist
Per the comment block (lines 43-65) and docs/CREDIT.md, enforcement "lives in backend/model.ts's credit-aware wrapper" because handleLLMStart "swallows throws". But backend/model.ts:39-45 is just the env-fallback wrapper — there is no credit-aware wrapper, and the proxy at app/api/[..._path]/route.ts:130-135 is the only enforcement point today. Meanwhile lib/credit/errors.ts:1-17 and lib/credit/invoke.ts:8-13 document CreditExceededError as "Thrown by CreditTrackingHandler.handleLLMStart", and grep over the codebase shows no instanceof CreditExceededError catch in any agent / node file — so creditExceededReply is dead code.
Pick one of:
- Wire the wrapper:
getChatModelshouldawait checkCredit(userId)(it hasoptsalready, but needsuserIdfrom metadata — easy via the resolved runtime) and throwCreditExceededErrorbefore returning the model. Then addtry { ... } catch (err) { if (err instanceof CreditExceededError) return { messages: [creditExceededReply(err)] }; throw err; }to the four nodes (router-agent-node.ts,chat-agent.ts,code-agent.ts,crypto-agent.ts,weather-agent.ts,thread-summarize-node.ts,rename-thread-agent-node.ts). - Delete
CreditExceededError,creditExceededReply, and the misleading comments — the proxy gate is sufficient because all SDK traffic goes through the/api/[..._path]proxy.
Right now CreditTrackingHandler.handleLLMStart calls super.handleLLMStart only via BaseCallbackHandler's default, which doesn't throw, and the proxy is the sole gate. The docs/code/comments must agree.
Review summaryNice feature, well-tested at the happy paths, and CLAUDE.md rules are mostly satisfied ( Must fix before merge
Test coverage (rule #2)None of the five issues above has a failing test that's currently exercised:
Notes (not blocking, FYI)
|
Closes #15
Problem
The chat agent has no usage cap — a single runaway session can rack up unbounded OpenAI spend with no operator visibility, and users have no per-account budget to constrain themselves with.
What changed
Credit cap (enforcement + log)
app/api/[..._path]/route.ts— proxy nowwithAuth-wrapped (anonymous traffic → 401) and runscheckCredit(user.id)on POST/PUT/PATCH. Over-cap → synthesizes an SSE stream carrying ashow_credit_cardtool_call, no LangGraph round-trip.lib/credit/check.ts— UTC-aligned rolling-window SUM overcredit_usage_log; admin role short-circuits to unlimited.lib/credit/callback.ts—CreditTrackingHandler(BaseCallbackHandler) is the only writer ofcredit_usage_log. Singleton inbackend/callbacks.ts, wired into bothagentandbackground_agentgraphs.lib/credit/{schema,charge,build-model,zod,errors,invoke,status}.ts— schema,computeCreditsmath,findProviderId/getModelRatecallback helpers, Zod, deadCreditExceededError(kept for symmetry), client-sideloadCreditStatus(1s TTL + in-flight collapse).Provider registry (DB-backed, replaces env-only model lookup)
lib/provider/{schema,admin,model-registry}.ts—providertable holds encrypted API keys (AES-256-GCM) + per-model rates.getChatModelFromDBreturns aChatOpenAI;getChatModelfalls back to env on DB miss. LRU + 60s cross-process TTL.backend/model.ts— rewired throughgetChatModel.Admin UI
app/admin/page.tsx+app/admin/admin-tabs.tsx— three tabs (Providers / Roles / Users),FormDialog+DropdownMenuprimitives, sentence-case copy, last-admin guard, default-provider protection,router.refresh()after mutations.app/api/admin/{providers,roles,users}/**/route.ts— full CUD; 409PROTECTEDonDELETE /api/admin/providers/default, 409LAST_ADMINon demote/ban/delete of only admin, 409DUPLICATE_KEY/DUPLICATE_MODELon name collisions, 409ROLE_IN_USEon deleting referenced role.Auth + ban
lib/auth/encryption.ts—loadKek,aesGcmEncrypt/Decrypt,deriveKeyName(first-3 + ellipsis + last-4), readsLLM_KEY_ENCRYPTION_KEYlazily per operation.lib/auth/with-auth.ts—withAuth({ role }, handler)overload (rule [Feat]: Email verification has no result page between verify and login redirect #9). Exact role match; FK-corruptroleIdfalls back to"user"so admin routes still reject.lib/auth/config.ts—databaseHooks.session.create.beforeblocks new sessions for banned users.db/migrations/0004_nappy_bloodstorm.sql—user.bannedcolumn.app/api/admin/users/[id]/route.ts— PATCHbanned:trueDELETEs every session row in the same handler for immediate cutoff.Other
app/api/credit/{status,history}/route.ts— user-facing cap state + paginated call history.components/{credit,auth,settings}/—CreditProgress,CreditHeader,CreditSummaryCard,CreditUsageSlot,CreditHistory(useInfiniteQuery), Settings → Credits tab.components/tool-ui/credit/credit-card.tsx— proxy-injectedshow_credit_cardrender.backend/callbacks.ts— lifted out ofobservability/soagent+background_agentshare one CapturingHandler + one CreditTrackingHandler.Tests
pnpm test— 868 / 868 pass (87 files). Tests cover: proxy credit gate, callback lifecycle, encryption round-trip, role gate overload, admin CUD including conflict + last-admin paths, history pagination, model-registry LRU + cache invalidation, schema.Docs
Per CLAUDE.md rule #1 / #10 / engineering rule 1, all in this PR:
docs/APIS.md— new Admin (Providers / Roles / Users), Credit history (/api/credit/*), and full Proxy section covering auth gate + credit gate +userIdinjection + header forwarding + OPTIONS + streaming passthrough.docs/ADMIN.md— full route reference + Secrets handling + Provider shape + Role management + Bootstrap admin + UI.docs/CREDIT.md(new) — roles & caps, UTC-aligned window, credit math, where the cap is enforced (proxy), where the log is written (callback), metadata contract, failure modes, user APIs.docs/AUTH.md—withAuthrole gate overload,LLM_KEY_ENCRYPTION_KEY+INITIAL_ADMIN_EMAILenv vars, last-admin / ban mechanics.docs/DEPLOY.md+skills/langgraph-app-maintain.md—LLM_KEY_ENCRYPTION_KEY+INITIAL_ADMIN_EMAILprerequisites + reset scenarios.docs/DB.md—role,provider,credit_usage_logtables; UTC-anchored window model;bannedcolumn;base_urlcolumn; Code → table map.docs/PROVIDERS.md(new) — DB-backed registry, LRU + 60s cross-process TTL tradeoff, seededdefaultrow, env fallback.docs/TOOLS.md— Credit tool group row +show_credit_cardentry (client-only render).docs/TODOS.md— deleted (open follow-ups now live as GitHub issues / TODO comments).README.md— Experience section, credit/admin features, refreshed env table, project layout, Database section.package.json— description, keywords, homepage, bugs, author, repository.🤖 Generated with Claude Code
Summary by cubic
Adds per-user, rolling-window credit caps for all LLM calls and an admin console to manage providers, model rates, roles, and users. Implements issue #15; includes guards, security hardening, and doc updates.
New Features
show_credit_cardtool; added/api/credit/{status,history}and a Settings → Credits view.credit_usage_logvia a sharedCreditTrackingHandler; credits derived from per-model rates; registered on chat and background graphs.baseUrlon the provider, and a 60s LRU; agents resolve chat models through the registry with env fallback; admin CUD busts the cache.apiKeysfrom providerPATCH(keys must go through/keysencrypt path);withAuthrole gate overload; user dropdown shows credit usage; docs updated (requiredbaseUrl,409 PROTECTEDon default provider delete, keys route body).Migration
role,provider,credit_usage_log, anduser.banned; seedsguest/user/admin; movesbaseUrlto provider).LLM_KEY_ENCRYPTION_KEY(32-byte hex) andINITIAL_ADMIN_EMAIL./admin(env fallback works until the registry is set).Written for commit aaa75ea. Summary will update on new commits.
Greptile Summary
This PR introduces per-role rolling-window credit caps on every LLM call, a DB-backed provider registry with AES-256-GCM encrypted API keys, and a full admin console (Providers / Roles / Users) with last-admin and default-provider guards. It is a large, well-tested feature (868 passing tests) with solid architectural choices — the proxy-level gate, callback-only write path, and
withAuthrole overload are all clean.checkCreditis called in the proxy before every POST/PUT/PATCH; over-cap requests synthesize a fabricatedshow_credit_cardSSE stream without touching LangGraph, andCreditTrackingHandlerwritescredit_usage_logentries after every successful or failed LLM call.getChatModelFromDBresolves models via a 60s LRU cache;backend/model.tswraps it with an env-var fallback. Encrypted keys are managed through/api/admin/providers/[id]/keys, though thePATCH /api/admin/providers/[id]endpoint exposes anapiKeysfield in its schema that bypasses the encryption flow.Confidence Score: 3/5
The core credit gate and callback accounting work correctly, but four defects in the new admin and credit-check paths need fixing before this ships.
The credit enforcement path is sound and well-tested. Four defects need fixing: checkCredit throws a TypeError rather than returning a graceful result when the user role FK is missing; the provider POST surfaces a raw DB error on duplicate IDs; the last-admin guard in user PATCH/DELETE is a read-then-write without a transaction, allowing two concurrent demotions to clear all admins; and the PATCH provider endpoint lets an admin write unencrypted key material directly to the DB. None affect the happy path in tests, but all are triggered by realistic edge-case inputs or concurrent requests.
lib/credit/check.ts (crash on empty result), app/api/admin/providers/route.ts (duplicate ID unhandled), app/api/admin/users/[id]/route.ts (TOCTOU in last-admin guard), app/api/admin/providers/[id]/route.ts (raw apiKeys in PATCH schema)
Security Review
PATCH /api/admin/providers/[id]acceptsapiKeysthroughproviderPatchSchemaand passes the array directly todb.update()without routing throughencryptApiKey. Plaintext sent asencryptedKeyis stored verbatim; failure only surfaces at decryption time.lib/auth/encryption.tsis correct: random 12-byte IV per encryption, auth-tag appended, GCM tag mismatch propagates as a throw.Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Client participant Proxy as app/api/[..._path] participant CreditCheck as checkCredit() participant LangGraph participant Callback as CreditTrackingHandler participant DB as Postgres Client->>Proxy: POST /api/threads/.../runs/stream Proxy->>Proxy: withAuth - verify session Proxy->>CreditCheck: checkCredit(userId) CreditCheck->>DB: SELECT role.creditLimit + SUM(credits) WHERE window DB-->>CreditCheck: used / limit alt Over cap CreditCheck-->>Proxy: "allowed=false" Proxy-->>Client: SSE stream with show_credit_card tool_call else Under cap CreditCheck-->>Proxy: "allowed=true" Proxy->>LangGraph: Forward request with userId in config.metadata LangGraph->>Callback: handleLLMStart(runId, metadata) Callback->>Callback: Cache RunMeta by runId LangGraph->>LangGraph: Model invocation LangGraph->>Callback: handleLLMEnd(output, runId) Callback->>DB: findProviderId + getModelRate Callback->>DB: INSERT credit_usage_log LangGraph-->>Proxy: SSE stream Proxy-->>Client: SSE stream passthrough end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Client participant Proxy as app/api/[..._path] participant CreditCheck as checkCredit() participant LangGraph participant Callback as CreditTrackingHandler participant DB as Postgres Client->>Proxy: POST /api/threads/.../runs/stream Proxy->>Proxy: withAuth - verify session Proxy->>CreditCheck: checkCredit(userId) CreditCheck->>DB: SELECT role.creditLimit + SUM(credits) WHERE window DB-->>CreditCheck: used / limit alt Over cap CreditCheck-->>Proxy: "allowed=false" Proxy-->>Client: SSE stream with show_credit_card tool_call else Under cap CreditCheck-->>Proxy: "allowed=true" Proxy->>LangGraph: Forward request with userId in config.metadata LangGraph->>Callback: handleLLMStart(runId, metadata) Callback->>Callback: Cache RunMeta by runId LangGraph->>LangGraph: Model invocation LangGraph->>Callback: handleLLMEnd(output, runId) Callback->>DB: findProviderId + getModelRate Callback->>DB: INSERT credit_usage_log LangGraph-->>Proxy: SSE stream Proxy-->>Client: SSE stream passthrough endComments Outside Diff (1)
lib/credit/check.ts, line 597-605 (link)const [{ creditLimit, windowHours, roleName }]destructures the first element of the query result array. If the user row exists but itsroleIdFK has no matchingrolerow (theinnerJoinreturns nothing), the array is empty and the destructure throwsTypeError: Cannot destructure property 'creditLimit' of 'undefined'. This turns every blocked user's request into an unhandled 500 rather than a graceful deny.The comment in
lib/auth/role-queries.tsexplicitly flags this scenario as "extremely rare, but defensive" —checkCreditshould apply the same guard: check for an undefined result and treat the missing row as a deny or fall back to the defaultuserrole limits.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "docs: cover proxy gates + key rotate/ren..." | Re-trigger Greptile
Context used: