Skip to content

Proj/rework llm billing#653

Merged
bao-byterover merged 16 commits into
mainfrom
proj/rework-llm-billing
May 14, 2026
Merged

Proj/rework llm billing#653
bao-byterover merged 16 commits into
mainfrom
proj/rework-llm-billing

Conversation

@ncnthien
Copy link
Copy Markdown
Collaborator

Summary

  • Problem:
  • Why it matters:
  • What changed:
  • What did NOT change (scope boundary):

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Linked issues

  • Closes #
  • Related #

Root cause (bug fixes only, otherwise write N/A)

  • Root cause:
  • Why this was not caught earlier:

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only
  • Test file(s):
  • Key scenario(s) covered:

User-visible changes

List user-visible changes (including defaults, config, or CLI output).
If none, write None.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording

Checklist

  • Tests added or updated and passing (npm test)
  • Lint passes (npm run lint)
  • Type check passes (npm run typecheck)
  • Build succeeds (npm run build)
  • Commits follow Conventional Commits format
  • Documentation updated (if applicable)
  • No breaking changes (or clearly documented above)
  • Branch is up to date with main

Risks and mitigations

List real risks for this PR. If none, write None.

  • Risk:
    • Mitigation:

wzlng and others added 13 commits April 30, 2026 14:52
Adds the billing transport layer (BillingEvents + team:list) backed by an
HttpBillingService against BRV_BILLING_BASE_URL and a file-backed pinned-team
store. The header trigger drops
…der.json, non-interactive UX

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pinned team from suggestions, preserve unknown config keys
[ENG-2512] rework billing chain: DTO + storage + non-interactive UX
…h race

- Remove daemon-side workspace fallback from `resolveBillingTeamId`; it's now a pure prediction helper (pin / single-paid / undefined).
- Add `projectPath` to `billing:getPinnedTeam`, `billing:setPinnedTeam`, and `auth:getState` request bodies so the daemon resolves the project explicitly per request instead of via per-client state.
- Add `computeTeamPreselection` so the team picker can visually pre-select a row without writing the pin to disk until the user clicks Confirm.
- `useBillingDisplay`: fall through to auto-pick when the pin is stale; ignore pin entirely when the user has no paid teams (free monthly always wins).
- Header: drop the workspace fallback; show the team that's actually being billed.
- `team-select-step`: disable Confirm when the current selection isn't in the team list (stale workspace id or removed team).
- `BrvApiClient.request`: pre-check `socket.connected` and fast-fail on disconnect so requests don't silently wait the full 5s timeout during project-switch socket teardown.
…orkspace fallback in provider list, harden tests

- billing-handler: reject empty `projectPath` for `billing:getPinnedTeam` and `billing:setPinnedTeam` so a stray empty string from the client can't end up writing the pin to the daemon's CWD.
- provider-select-step: drop the `?? teamId` workspace fallback to match the header (was the only remaining surface still showing the old behavior).
- set-pinned-team: read `projectPath` at mutate-call time via `useTransportStore.getState()` so a project switch mid-dialog can't write the pin to the previous project.
- Tests: add behavioral coverage for `BrvApiClient.request` (not-connected pre-check, disconnect-before-ack, normal ack) and for empty-projectPath rejection in both billing handlers.
…queries, assert listener cleanup

- get-pinned-team / get-auth-state: gate react-query on `projectPath !== ''` so we don't fire a doomed request on initial mount (cached error envelope is now skipped entirely).
- api-client tests: assert `socket.off('disconnect', ...)` runs on both the ack-success and timeout paths so a future regression that drops the cleanup is caught.
…rojectPath check

Reverse the spread order in `useGetAuthState` (was queryConfig last) so the projectPath gate was being silently overridden by `auth-initializer.tsx`'s `enabled: apiClient !== null`. Switch both hooks to an explicit AND-merge form (`base.enabled !== false && (queryConfig?.enabled ?? true)`) so neither the gate nor a caller's enable check can clobber the other.
feat: [ENG-2512] rework billing-team resolution and fix project-switch race
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

Claude finished @ncnthien's task in 7m 16s —— View job


PR Review: Rework LLM Billing

  • Read core billing infrastructure (services, schemas, DTOs)
  • Review transport handlers (billing, team, auth, status)
  • Review CLI commands changes
  • Review webUI billing/team integration
  • Review tests
  • Post inline comments and summary

Overall: 4 / 5 — solid, ships-ready with one UX bug worth fixing before merge

This is a thoughtful rework. The discriminated-union StatusBillingDTO, the per-project brv-provider.json pin (replacing the previous workspace fallback), the buildStatusBilling pure-function split, and the parallel daemon-stamped pin propagation via BILLING_PIN_CHANGED all read like deliberate, well-tested design choices. Test coverage is genuinely good — buildStatusBilling, resolveBillingForProject, HttpBillingService, the new transport handlers, and the CLI billing helpers all have meaningful cases (including the no-pin / multi-paid-org edge). Web UI utilities (format-credits, get-billing-tone, compute-team-preselection, has-paid-team) are nicely isolated and individually tested.

Inline comments

praise:

  • Clean Outside-In: buildStatusBilling is a pure function with comprehensive table-driven tests — the right shape.
  • BillingHandler.setupSetPinnedTeam correctly broadcasts BILLING_PIN_CHANGED only on store success, and agent-process.ts consumes it without re-reading config. Solid daemon-stamping pattern, consistent with reviewDisabled stamping.
  • ProjectAssociationInitializer proactively invalidates AUTH_STATE, PINNED_TEAM, and LIST_BILLING_USAGE on project association — the right cache discipline.
  • printBillingLine swallowing daemon errors so curate/query don't fail just because billing is offline is the right call.

issue (UX, worth fixing pre-merge): resolve-billing-source.ts — when the user has 2+ paid orgs and no pin, freeUserLimit is intentionally not fetched and buildStatusBilling falls through to freeSource(undefined), so the CLI prints Billing: Personal free credits even though the user has multiple paid teams. Misleading; web UI side handles this correctly with needsPickPrompt. See inline.

issue (UX): TeamSelectStep (and the CLI --team flag) lets the user pin a FREE-tier team. The resulting status shows (usage unavailable) instead of disallowing the action. Disable free rows or validate at pin time. Inline.

issue (non-blocking): HttpBillingService.getUsages uses Promise.all, so a transient tiers-endpoint failure throws away the whole call and resolveBillingForProject returns undefined. Promise.allSettled (or .catch(() => []) on tiers) would degrade to "everyone is FREE tier" — same path the code already takes for missing tier entries. Inline.

issue (low/security): setupGetPinnedTeam and setupSetPinnedTeam are the only billing handlers that don't check auth. Daemon is localhost-only so blast radius is small, but the inconsistency means a logged-out client can still write/read pin state. Inline.

issue (low): FileBillingConfigStore.setPinnedTeamId is a non-atomic read-modify-write — concurrent writers can lose pin updates and clobber unknown keys preserved from the same file. Easy fix: write-temp-then-rename. Inline.

nitpick: useListTeams() in header.tsx fires unconditionally; gate on isAuthorized. Inline.

nitpick: billing:getUsage always fetches the full org list then filters client-side. Either push the filter down or remove the endpoint — billing:listUsage already returns the full map keyed by id. Inline.

Other observations (not inlined, no action needed)

  • auth-handler.ts:213 calls this.disconnectByteRoverProvider() from onAuthExpired without await. Probably fine (fire-and-forget on expiry) but worth a comment if it's deliberate.
  • provider-flow-dialog.tsx:243 handleProviderSelect deps include onProviderActivated and resetAndClose though they're not used in the body. Cosmetic.
  • The clientId in setupGetUsage/setupListUsage handlers isn't used; can drop the second handler arg for clarity.
  • Test files generally re-stub ProxyConfig.getProxyAgent correctly and verify x-byterover-session-id headers — matches the CLAUDE.md HTTP-nock convention.

Recommendation

Address the "Personal free credits" UX bug (the multi-paid-org case) and the free-tier pin guardrail before shipping; the rest are non-blocking and can land as follow-ups. Pin-write authentication and atomic write are worth doing soon but don't gate this PR.
· branch: proj/rework-llm-billing

Comment on lines +65 to +72
public async getUsages(sessionKey: string): Promise<BillingUsageDTO[]> {
const [rawUsages, tiers] = await Promise.all([this.fetchRawUsages(sessionKey), this.getTiers(sessionKey)])
const tierByOrg = new Map(tiers.map((t) => [t.organizationId, t]))
return rawUsages.map((usage) => {
const tier = tierByOrg.get(usage.organizationId)
return {...usage, isTrialing: tier?.isTrialing ?? false, tier: tier?.tier ?? 'FREE'}
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): Promise.all rejects if either /billing/usages or /billing/organizations/tiers fails, so a transient tiers outage will fail the whole getUsages call even though the raw usages alone would still be a useful response (every org would default to tier: 'FREE', which is the same fallback path you already implement for missing tier entries).

Consider Promise.allSettled so a tiers failure degrades gracefully:

const [rawResult, tiersResult] = await Promise.all([
  this.fetchRawUsages(sessionKey),
  this.getTiers(sessionKey).catch(() => [] as BillingOrganizationTierDTO[]),
])

That keeps the resilient billing-pill UX (status falls back to "FREE" tier) instead of silently swallowing the whole resolve into undefined upstream in resolveBillingForProject.

Comment on lines +83 to +97
private setupGetPinnedTeam(): void {
this.transport.onRequest<BillingGetPinnedTeamRequest, BillingGetPinnedTeamResponse>(
BillingEvents.GET_PINNED_TEAM,
async (data) => {
if (!data.projectPath) return {error: 'projectPath is required'}
try {
const store = this.billingConfigStoreFactory(data.projectPath)
const teamId = await store.getPinnedTeamId()
return teamId === undefined ? {} : {teamId}
} catch (error) {
return {error: getErrorMessage(error)}
}
},
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): setupGetPinnedTeam and setupSetPinnedTeam are the only billing handlers that don't gate on authStateStore.getToken()?.isValid(). They only validate projectPath. Since the pin is consumed at the next byterover task to decide which team to bill, an unauthenticated client can quietly write a pin that the user wouldn't normally see.

Even though the daemon is localhost-only, the rest of the handlers in this file follow the "must be signed in to touch billing state" rule, so the inconsistency is the bug. Adding if (!token?.isValid()) return {error: NOT_AUTHENTICATED_ERROR ...} to both endpoints keeps the contract uniform and prevents stale pins from outliving a logout.

Comment on lines +32 to +44
public async setPinnedTeamId(teamId: string | undefined): Promise<void> {
const json = await this.readJson()
const billing = {...json.billing}
if (teamId === undefined) {
delete billing.pinnedTeamId
} else {
billing.pinnedTeamId = teamId
}

const next: ProviderConfigJson = {...json, billing}
if (Object.keys(billing).length === 0) delete next.billing
await this.writeJson(next)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (low): setPinnedTeamId performs a non-atomic read → modify → writeFile. If two clients (e.g. webui dialog and brv providers connect --team) race on the same project, the later writer's view of json will not include the earlier writer's mutation and the earlier write is silently lost.

The blast radius is small (only one key in billing), but the file also preserves arbitrary unknown keys from the same JSON, so a concurrent write to the pin can also clobber unrelated keys an outside tool added. A simple fix: write to a temp path next to the file then fs.rename (atomic on POSIX/Win). The added cost is negligible since this is rare (~user-initiated).

const isByteRoverActive = activeProvider?.id === BYTEROVER_PROVIDER_ID
const providerLabel = buildProviderLabel(activeProvider, activeConfig)

const {data: teamsData} = useListTeams()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: useListTeams() here fires unconditionally, even before sign-in. The server returns {error: NOT_AUTHENTICATED_ERROR} (not a rejection), so the query "succeeds" but the response carries an error string the UI never surfaces — net effect is a wasted round trip on every unauthenticated render and a noisy log channel.

useListTeams already accepts {enabled}:

Suggested change
const {data: teamsData} = useListTeams()
const isAuthorized = useAuthStore((s) => s.isAuthorized)
const {data: teamsData} = useListTeams({enabled: isAuthorized})

Same pattern as useListBillingUsage in useBillingDisplay.

Comment on lines +228 to +247
teams.map((team) => {
const teamUsage = usageByTeam[team.id]
const roleLabel = team.id === workspaceTeamId ? 'Workspace' : team.isDefault ? 'Default' : undefined
return (
<TeamRow
avatar={<TeamAvatar avatarUrl={team.avatarUrl} name={team.displayName} />}
badges={
<>
{teamUsage && <TierBadge isTrialing={teamUsage.isTrialing} tier={teamUsage.tier} />}
{roleLabel && <RowBadge>{roleLabel}</RowBadge>}
</>
}
credits={<CreditsPill tone={getBillingTone(teamUsage)} usage={teamUsage} />}
key={team.id}
name={team.displayName}
onSelect={() => setSelection(team.id)}
selected={selection === team.id}
/>
)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (UX): This list renders every team the user belongs to, including FREE-tier teams. Selecting one of those teams persists a pin that the backend then resolves to a paid usage entry that doesn't exist — build-status-billing.ts:35 returns {organizationId: resolved, source: 'paid'} with no remaining/tier, which formatBillingLine then renders as Billing: <name> (usage unavailable). Same trap exists in the CLI's --team flag (no tier filter on matchTeam).

Two reasonable options:

  1. Hide / disable rows where usageByTeam[team.id]?.tier === 'FREE' so users can't pick a tier that can't be billed against.
  2. Validate in setBillingPin (server-side) that the team is in the user's paid-org list, and surface "Pick a paid team" if not.

The free-tier message you already show in showFreeTierView only fires when all the user's teams are free — mixed accounts can still hit this footgun.

Comment on lines +46 to +58
const paidUsages = usagesResult.usages.filter((u) => u.tier !== 'FREE')
const freeUserLimit =
paidUsages.length === 0
? await deps.billingService.getFreeUserLimit(sessionKey).catch((): undefined => undefined)
: undefined

return buildStatusBilling({
activeProvider,
freeUserLimit,
isAuthenticated: true,
paidUsages,
pinnedTeamId,
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (UX): When the user has 2+ paid orgs and no pin set, paidUsages.length === 0 is false so freeUserLimit stays undefined. buildStatusBilling then sees resolved === undefined (no pin, >1 paid org) and falls through to freeSource(undefined) which returns {source: 'free'} with no remaining/total.

The CLI then prints Billing: Personal free credits even though the user actually has multiple paid teams to choose from — that string is genuinely misleading. The web UI already handles this with needsPickPrompt and an "amber" warn pill, but the CLI side surfaces it as "you're on free credits."

Either fetch freeUserLimit unconditionally for the byterover branch (one extra call) and surface a 'needs-pick' source variant on StatusBillingDTO so the CLI can print "Pick a billing team — run brv providers connect byterover --team <name>." or keep the data layer the same but special-case paidUsages.length > 1 && pinnedTeamId === undefined in formatBillingLine to print the prompt. The current state silently misrepresents what the user is being billed against.

Comment on lines +99 to +121
private setupGetUsage(): void {
this.transport.onRequest<BillingGetUsageRequest, BillingGetUsageResponse>(
BillingEvents.GET_USAGE,
async (data) => {
const token = this.authStateStore.getToken()
if (!token?.isValid()) {
return {error: NOT_AUTHENTICATED_ERROR}
}

try {
const usages = await this.billingService.getUsages(token.sessionKey)
const usage = usages.find((u) => u.organizationId === data.organizationId)
if (!usage) {
return {error: `No billing usage found for organization ${data.organizationId}`}
}

return {usage}
} catch (error) {
return {error: getErrorMessage(error)}
}
},
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: setupGetUsage fetches every organization the user belongs to and then filters client-side. If the IAM service exposes the per-org endpoint (/billing/usages/{orgId}), pushing the filter down avoids transferring/joining usage data the caller will discard, especially for users with many orgs.

If keeping the bulk fetch is intentional (single round-trip, simpler caching), consider removing this endpoint entirely — LIST_USAGE already gives the caller the full map keyed by organizationId, so callers can just index into it. The current shape is "list, then return one entry" which the consumer is going to repeat anyway when it later wants the full list.

@bao-byterover bao-byterover merged commit 1c54787 into main May 14, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request May 14, 2026
bao-byterover added a commit that referenced this pull request May 27, 2026
TUI sends no body for auth:getState, so the {projectPath} destructure
added in #653 threw and the existing catch-all swallowed it into
{isAuthorized: false} — forcing the TUI into the provider chooser and
re-prompting byterover login on every startup. The valid token already
proves the user is logged in; treat projectPath as optional and pull it
via optional chaining so a missing body returns the correct auth state.
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.

3 participants