Skip to content

Commit fb01326

Browse files
idoubiclaude
andcommitted
feat(billing+identity): usage/quota APIs, per-call token log, user identity cleanup
Billing: - GET /v1/usage, PUT/GET/DELETE /v1/quota APIs for upstream SaaS billing - token_usage_log table: append-only per-LLM-call audit trail (vs daily UPSERT) - Quota enforcement in agent loop entry (HandleMessage + HandleMessageStream) - session_messages gains provider/model columns for per-message LLM attribution Identity: - users.owner_user_id: explicit parent link replacing overloaded apikey_id - role=channel_user for IM-originated users (was indistinguishable from app_user) - EnsureChatter separates channel user creation from API app_user provisioning - resolveChatter scoped to channel owner ID (fixes cross-tenant chatter collision when multiple app_users share the same platform API key) - extID drops accountID so chatter identity survives bot reconnection and is shared across agents under the same owner - Migration backfills owner_user_id, fixes roles, normalizes username/email, cleans up apikey_id Fixes: - web_fetch UTF-8 truncation: back up to valid rune boundary before cutting - channel_user blocked from web login alongside app_user Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 365a7e1 commit fb01326

26 files changed

Lines changed: 1583 additions & 97 deletions

cmd/fastclaw/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ func runGateway(port int) error {
185185
gw.SetChatEvents(webSrv.ChatEventHub())
186186

187187
apiSrv := api.NewServer(&apiResolver{gw: gw}, authResolver, gwCfg)
188+
apiSrv.SetMeter(gw.Usage())
189+
apiSrv.SetQuotaStore(gw.QuotaStore())
188190
webSrv.SetAPIServer(apiSrv)
189191

190192
// Coding-agent project runtime: long-lived dev-server sandbox +

internal/agent/loop.go

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ type Agent struct {
106106
// gateway wires it in via SetMeter at boot — local-only dev runs
107107
// leave it nil and metering becomes a no-op via meterTokens().
108108
meter usage.Meter
109+
// quotaStore is the per-user billing quota store. When set, the
110+
// agent loop checks the owner's quota before processing a turn.
111+
// Nil means no quota enforcement (unlimited).
112+
quotaStore usage.QuotaStore
109113
// sandboxPool is the per-user (agent + session) sandbox pool. Set
110114
// once at boot/hot-reload by attachSandboxToAgents; bindSession
111115
// pulls a session-scoped executor from it at the top of every turn
@@ -650,27 +654,52 @@ func (a *Agent) OwnerUserID() string { return a.ownerUserID }
650654
// invocation. Nil is fine — meterTokens() is a no-op when unset.
651655
func (a *Agent) SetMeter(m usage.Meter) { a.meter = m }
652656

657+
// SetQuotaStore wires the billing quota store. Called by the gateway at
658+
// boot / hot-reload alongside SetMeter. Nil disables quota enforcement.
659+
func (a *Agent) SetQuotaStore(qs usage.QuotaStore) { a.quotaStore = qs }
660+
661+
// checkQuota returns a non-empty rejection message when the agent's
662+
// owner has exceeded their billing quota. Returns "" when the request
663+
// should proceed (no quota, unlimited, or still under limit).
664+
func (a *Agent) checkQuota(ctx context.Context) string {
665+
if a.quotaStore == nil || a.meter == nil {
666+
return ""
667+
}
668+
status, err := usage.CheckQuota(ctx, a.quotaStore, a.meter, a.ownerUserID)
669+
if err != nil || status.Allowed {
670+
return ""
671+
}
672+
return fmt.Sprintf("Sorry, your usage quota has been exceeded (used %d/%d tokens, %d/%d requests). Your quota resets on %s. Please contact your service provider to upgrade your plan.",
673+
status.TokensUsed, status.MonthlyTokenLimit,
674+
status.RequestsUsed, status.MonthlyRequestLimit,
675+
status.ResetsAt)
676+
}
677+
653678
// meterTokens records one Chat call's token counts. Safe to call with
654679
// zero usage (still bumps request_count). Errors are logged but never
655680
// propagated — metering must not break the chat path. The agent's
656681
// configured model string carries the provider prefix when a per-agent
657682
// override is set; we split it so the meter stores provider and model
658683
// in their own columns rather than mashing them together.
659-
func (a *Agent) meterTokens(ctx context.Context, sessionKey string, u provider.Usage) {
684+
// durationMs is the wall-clock time of the LLM call; pass 0 when not
685+
// measured (the daily bucket doesn't use it, only the log table).
686+
func (a *Agent) meterTokens(ctx context.Context, sessionKey string, u provider.Usage, durationMs int64) {
660687
if a.meter == nil {
661688
return
662689
}
663690
prov, mdl := provider.SplitProviderModel(a.model)
664-
err := a.meter.RecordTokens(ctx, a.ownerUserID, a.agentID, sessionKey, prov, mdl,
665-
usage.Tokens{
666-
Input: u.InputTokens,
667-
Output: u.OutputTokens,
668-
CacheRead: u.CacheReadTokens,
669-
CacheCreation: u.CacheCreationTokens,
670-
})
671-
if err != nil {
691+
t := usage.Tokens{
692+
Input: u.InputTokens,
693+
Output: u.OutputTokens,
694+
CacheRead: u.CacheReadTokens,
695+
CacheCreation: u.CacheCreationTokens,
696+
}
697+
if err := a.meter.RecordTokens(ctx, a.ownerUserID, a.agentID, sessionKey, prov, mdl, t); err != nil {
672698
slog.Warn("meter record failed", "agent", a.name, "error", err)
673699
}
700+
if err := a.meter.RecordTokenLog(ctx, a.ownerUserID, a.agentID, sessionKey, prov, mdl, t, durationMs); err != nil {
701+
slog.Warn("meter log failed", "agent", a.name, "error", err)
702+
}
674703
}
675704

676705
// streamChatToResponse is a drop-in replacement for provider.Chat that
@@ -1623,13 +1652,18 @@ func (a *Agent) handlePlanMode(ctx context.Context, msg bus.InboundMessage) stri
16231652
chatterUID := a.chatterUserID(msg)
16241653
ctx = sandbox.WithUserID(ctx, chatterUID)
16251654
ctx = store.WithChatterUserID(ctx, chatterUID)
1655+
ctx = store.WithChannel(ctx, msg.Channel)
16261656
sess := a.sessions.Get(msg.Channel, msg.AccountID, msg.ChatID, msg.ProjectID)
16271657
// Session.ctx() builds its OWN context from session-held fields
16281658
// rather than inheriting the caller's ctx — without binding the
16291659
// chatter onto sess itself, the WithChatterUserID we just stamped
16301660
// above never reaches AppendSessionMessage / SaveSession and the
16311661
// chatter_user_id column stays empty.
16321662
sess.SetChatter(chatterUID)
1663+
{
1664+
prov, mdl := provider.SplitProviderModel(a.model)
1665+
sess.SetProviderModel(prov, mdl)
1666+
}
16331667
// Steering during plan drafting: plan mode has no ReAct loop to drain
16341668
// into, so a mid-draft steer is parked in history and answered on
16351669
// the user's next turn — which matches the plan-mode contract
@@ -1680,7 +1714,7 @@ func (a *Agent) handlePlanMode(ctx context.Context, msg bus.InboundMessage) stri
16801714
emitEvent(ctx, ChatEvent{Type: "done"})
16811715
return "Sorry, I couldn't draft the plan — the LLM call failed."
16821716
}
1683-
a.meterTokens(ctx, sess.Key(), resp.Usage)
1717+
a.meterTokens(ctx, sess.Key(), resp.Usage, 0)
16841718

16851719
planMeta := map[string]any{"planMode": true}
16861720
sess.Append(provider.Message{
@@ -1757,6 +1791,15 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
17571791
return result.reply
17581792
}
17591793

1794+
// Quota gate: reject the turn early when the agent owner has
1795+
// exceeded their billing ceiling. Checked before plan-mode and
1796+
// the main ReAct loop so no LLM tokens are burned.
1797+
if rejection := a.checkQuota(ctx); rejection != "" {
1798+
emitEvent(ctx, ChatEvent{Type: "content", Data: map[string]any{"content": rejection}})
1799+
emitEvent(ctx, ChatEvent{Type: "done"})
1800+
return rejection
1801+
}
1802+
17601803
// Plan mode short-circuits the ReAct loop: tools off, the model
17611804
// emits a numbered plan, the user reviews it and replies normally
17621805
// (no planMode flag) on the next turn to execute. Lets users catch
@@ -1793,6 +1836,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
17931836
// continue to list "all sessions on my bots"; chatter_user_id
17941837
// records the actual participant for per-chatter queries.
17951838
ctx = store.WithChatterUserID(ctx, chatterUID)
1839+
ctx = store.WithChannel(ctx, msg.Channel)
17961840
// Per-turn channel context for the skill-refresh diagnostic. Lets
17971841
// us correlate the "skills summary refreshed" log emitted inside
17981842
// refreshSkillsFromStore with the channel the request arrived on,
@@ -1807,6 +1851,10 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
18071851
// reach AppendSessionMessage / SaveSession on its own — sess has to
18081852
// carry the chatter itself.
18091853
sess.SetChatter(chatterUID)
1854+
{
1855+
prov, mdl := provider.SplitProviderModel(a.model)
1856+
sess.SetProviderModel(prov, mdl)
1857+
}
18101858
// Bind the registry to this chat's session so workspace.Store reads
18111859
// + writes get session-scoped paths and (when a sandbox pool is
18121860
// wired) the executor used by exec/read_file/list_dir is tied to a
@@ -1993,7 +2041,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
19932041
emitEvent(ctx, ChatEvent{Type: "done"})
19942042
return "Sorry, I encountered an error processing your request."
19952043
}
1996-
a.meterTokens(ctx, sess.Key(), resp.Usage)
2044+
a.meterTokens(ctx, sess.Key(), resp.Usage, 0)
19972045
a.maybeRecoverToolCalls(resp)
19982046

19992047
if !resp.HasToolCalls() {
@@ -2274,7 +2322,7 @@ func (a *Agent) HandleMessage(ctx context.Context, msg bus.InboundMessage) strin
22742322
finalResp, finalErr := a.streamChatToResponse(ctx, finalMessages, nil)
22752323
if finalErr == nil {
22762324
finalContent = finalResp.Content
2277-
a.meterTokens(ctx, sess.Key(), finalResp.Usage)
2325+
a.meterTokens(ctx, sess.Key(), finalResp.Usage, 0)
22782326
}
22792327
if finalContent == "" {
22802328
// Synthesis call itself failed or returned empty — fall back to
@@ -2534,11 +2582,17 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
25342582
return provider.NewStreamReader(ch)
25352583
}
25362584

2585+
// Quota gate — mirrors the check in HandleMessage.
2586+
if rejection := a.checkQuota(ctx); rejection != "" {
2587+
return a.stringStream(rejection)
2588+
}
2589+
25372590
chatterUID := a.chatterUserID(msg)
25382591
ctx = sandbox.WithUserID(ctx, chatterUID)
25392592
// Tag ctx so DBStore session writes stamp chatter_user_id — see
25402593
// the HandleMessage path for the rationale.
25412594
ctx = store.WithChatterUserID(ctx, chatterUID)
2595+
ctx = store.WithChannel(ctx, msg.Channel)
25422596
slog.Info("turn: refreshing skills",
25432597
"agent", a.name, "channel", msg.Channel, "chat_id", msg.ChatID, "user", chatterUID)
25442598
a.refreshSkillsFromStore(chatterUID)
@@ -2547,6 +2601,10 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
25472601
// for DBStore session writes — Session.ctx() rebuilds ctx from its
25482602
// own fields, so the chatter has to live on sess itself.
25492603
sess.SetChatter(chatterUID)
2604+
{
2605+
prov, mdl := provider.SplitProviderModel(a.model)
2606+
sess.SetProviderModel(prov, mdl)
2607+
}
25502608
a.bindSession(ctx, msg.Channel, msg.ChatID, msg.ProjectID)
25512609
a.registry.SetCallerIsAdmin(a.isAdminChatter(msg))
25522610
a.registry.SetGoalSessionKey(sess.SessionKey())
@@ -2628,7 +2686,7 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
26282686
slog.Error("LLM chat failed", "agent", a.name, "error", err)
26292687
return a.stringStream("Sorry, I encountered an error processing your request.")
26302688
}
2631-
a.meterTokens(ctx, sess.Key(), resp.Usage)
2689+
a.meterTokens(ctx, sess.Key(), resp.Usage, 0)
26322690
a.maybeRecoverToolCalls(resp)
26332691

26342692
if !resp.HasToolCalls() {
@@ -2684,7 +2742,7 @@ func (a *Agent) HandleMessageStream(ctx context.Context, msg bus.InboundMessage)
26842742
return
26852743
}
26862744
}
2687-
a.meterTokens(ctx, sess.Key(), streamUsage)
2745+
a.meterTokens(ctx, sess.Key(), streamUsage, 0)
26882746
msg := provider.Message{Role: "assistant", Content: full.String(), Thinking: thinking}
26892747
switch {
26902748
case len(rawAssistant) > 0:
@@ -2843,7 +2901,7 @@ func (a *Agent) streamFinalDeliveryAfterCap(ctx context.Context, inboundMsg bus.
28432901
return
28442902
}
28452903
}
2846-
a.meterTokens(ctx, sess.Key(), streamUsage)
2904+
a.meterTokens(ctx, sess.Key(), streamUsage, 0)
28472905
content := full.String()
28482906
if content == "" {
28492907
content = fmt.Sprintf("I've reached the maximum number of tool iterations (%d) and couldn't synthesize a final response. The work above represents what I gathered before hitting the limit.", a.maxToolIterations)

internal/agent/manager.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type managerOpts struct {
4747
workspaceStore workspace.Store
4848
dataStore store.Store
4949
meter usage.Meter
50+
quotaStore usage.QuotaStore
5051
userID string
5152
globalSkillsCfg config.SkillsCfg
5253
}
@@ -90,6 +91,14 @@ func WithMeter(m usage.Meter) ManagerOption {
9091
return func(o *managerOpts) { o.meter = m }
9192
}
9293

94+
// WithQuotaStore installs per-user billing quota enforcement on every
95+
// agent. The agent loop checks the owner's quota before processing a
96+
// turn — when exceeded, the user gets a friendly rejection and no LLM
97+
// tokens are burned. Omit to disable quota enforcement.
98+
func WithQuotaStore(qs usage.QuotaStore) ManagerOption {
99+
return func(o *managerOpts) { o.quotaStore = qs }
100+
}
101+
93102
// WithGlobalSkillsCfg propagates cfg.Skills (entries + agentEntries
94103
// holding skill apiKey/env per skill or per (agent,skill)) into agents
95104
// the manager constructs. Without this, buildAgent → NewAgent passes a
@@ -248,6 +257,9 @@ func (m *Manager) buildAgent(rc config.ResolvedAgent, prov provider.Provider, mb
248257
if m.opts.meter != nil {
249258
ag.SetMeter(m.opts.meter)
250259
}
260+
if m.opts.quotaStore != nil {
261+
ag.SetQuotaStore(m.opts.quotaStore)
262+
}
251263
return ag
252264
}
253265

internal/agent/tools/web_fetch.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,13 @@ func webFetchTool(ctx context.Context, r *Registry, rawArgs json.RawMessage) (st
276276
// Strip HTML tags
277277
text := stripHTML(string(body))
278278

279-
// Truncate to max length
279+
// Truncate to max length (UTF-8 safe: back up to a valid rune boundary).
280280
if len(text) > maxLen {
281-
text = text[:maxLen] + "\n[...truncated]"
281+
cut := maxLen
282+
for cut > 0 && cut < len(text) && text[cut]&0xC0 == 0x80 {
283+
cut-- // skip continuation bytes so we don't split a multi-byte rune
284+
}
285+
text = text[:cut] + "\n[...truncated]"
282286
}
283287

284288
return text, nil

0 commit comments

Comments
 (0)