Skip to content

fix(billing): correct Conversation API actor model + usage table#49

Merged
ABB65 merged 1 commit into
mainfrom
fix/conversation-api-billing-and-actor
May 15, 2026
Merged

fix(billing): correct Conversation API actor model + usage table#49
ABB65 merged 1 commit into
mainfrom
fix/conversation-api-billing-and-actor

Conversation

@ABB65
Copy link
Copy Markdown
Member

@ABB65 ABB65 commented May 15, 2026

Summary

Conversation API endpoint was wired through agent_usage with source='api' and userId=keyData.keyId, but:

  • agent_usage_source_check only accepts 'studio'|'byoa'
  • agent_usage.user_id FK → profiles(id), API key id is not a profile

Both fire on the first DB call. System was never production-deployed (no data to migrate). Fix restructures the model before the route can be safely opened.

Why a separate table, not nullable user_id in agent_usage

Reviewed during this session. The "nullable user_id + COALESCE-UNIQUE" shape was rejected because:

  • API key actors differ from user actors (lifecycle, scope, permissions, cap semantics)
  • (workspace, user, month, source) UNIQUE doesn't naturally extend with COALESCE — UNIQUE acrobatics for an alien actor
  • MCP Cloud keys will need the same key-keyed aggregate; pattern repeats

A dedicated api_message_usage table:

  • Preserves DB-enforced FK integrity end-to-end
  • Studio chat path untouched → zero regression surface
  • Trivially extensible for MCP Cloud later (same shape, different FK target)
  • Reporting via UNION/view if cross-actor totals are ever needed

Changes

Migration 006_api_message_usage_and_conversation_actor.sql

  1. api_message_usage (workspace, api_key, month) aggregate, RLS for workspace admins, service-role writes only.
  2. increment_api_usage_if_allowed RPC — enforces per-key cap AND workspace plan cap in one advisory-locked tx; reports which cap fired via reason discriminator ('ok' | 'key_limit' | 'workspace_limit').
  3. increment_api_usage_tokens RPC for post-AI token accounting.
  4. conversations.user_id relaxed to nullable; api_key_id uuid FK→conversation_api_keys ON DELETE CASCADE added; CHECK (num_nonnulls(user_id, api_key_id) = 1) enforces exactly one actor.

Provider surface

  • incrementAPIUsageIfAllowed / updateAPIUsageTokens RPC wrappers
  • createApiConversation — explicit method, not an overload, so call sites state intent
  • getConversation filter is now a discriminated union: { userId: string } | { apiKeyId: string }
  • getWorkspaceMonthlyAPIUsage repointed to new table (was reading always-empty agent_usage.source='api')

EE wiring

  • conversation-api.ts uses the new RPC + helpers; computes both caps via getEffectiveLimit (overage-enabled flows soft-cap correctly); 429 message names the right limit.
  • conversation-keys.ts update path now clamps monthly_message_limit to the workspace's current api.messages_per_month cap. Plan downgrades can no longer leave stale-high key limits.

Studio path

  • agent_usage schema and RPCs untouched.
  • saveChatResult source param narrowed to 'byoa' | 'studio''api' is invalid by construction now and routes through saveApiChatResult.

Out of scope (separate PRs, per agreed roadmap)

  • Aborted/failed billing semantics (commit_or_revert)
  • History budget refactor / source-aware budget
  • Prompt cache + AIProvider system-block array
  • Structured tool trace persistence (messages.content_blocks)
  • listConversations/deleteConversation for API keys (EE handler doesn't call them)

Test plan

  • pnpm typecheck clean
  • pnpm lint — 0 errors on changed files (only pre-existing warnings)
  • pnpm test:unit — 404 passed (was 403; new saveApiChatResult test added)
  • Apply migration to a Supabase test instance; verify the RPC returns { allowed: false, reason: 'key_limit' } and 'workspace_limit' under their respective scenarios
  • Manual: create a conversation key, hit /api/conversation/v1/{projectId}/message — must return 200 (or a feature-gate 403), no 500

The Conversation API endpoint was wired through `agent_usage` with
`source='api'` and `user_id=keyData.keyId`, but `agent_usage`'s CHECK
constraint only accepts `'studio'|'byoa'` and its `user_id` FK points
at `profiles(id)`. Both violations fire on the first DB call, so the
endpoint has been unreachable since it shipped. The system was never
production-deployed, so there is no data to migrate — this PR
restructures the model before the route can be opened.

Code review concluded that squeezing API usage into the per-user
`agent_usage` table with nullable user_id + COALESCE-UNIQUE was the
wrong shape: API key actors differ from user actors in lifecycle,
scope, permissions, and cap semantics. The same key-keyed aggregate
shape will be needed for MCP Cloud keys, so a dedicated table is the
forward-compatible model.

Migration 006:
- New `api_message_usage (workspace, api_key, month)` aggregate with
  message + token counters and a workspace-admin SELECT RLS policy.
- `increment_api_usage_if_allowed` RPC enforces BOTH the per-key
  monthly cap and the workspace plan cap in one advisory-locked
  transaction and reports which cap fired via a `reason`
  discriminator so the 429 message can name the right limit.
- `increment_api_usage_tokens` RPC for post-AI token accounting.
- `conversations.user_id` relaxed to nullable, `api_key_id` added as
  nullable FK → `conversation_api_keys`, and a
  `num_nonnulls(user_id, api_key_id) = 1` CHECK enforces exactly one
  actor per row. Existing rows all satisfy the check trivially.

Provider surface:
- `incrementAPIUsageIfAllowed` / `updateAPIUsageTokens` thin wrappers
  over the new RPCs.
- `createApiConversation` is its own method (not a `createConversation`
  overload) so the call site states intent. `getConversation` now
  takes a discriminated union `{ userId } | { apiKeyId }` filter so
  the type system rules out mixed ownership lookups.
- `getWorkspaceMonthlyAPIUsage` now reads from `api_message_usage`
  instead of the always-empty `agent_usage.source='api'` slice.

EE wiring:
- `conversation-api.ts` calls the new RPC, computes both caps via
  `getEffectiveLimit` (so overage-enabled workspaces fall through to
  the meter outbox), uses the new conversation helpers, and persists
  via the new `saveApiChatResult`.
- `conversation-keys.ts` update path now clamps
  `monthly_message_limit` to the current `api.messages_per_month`
  plan cap, matching the create path. Plan downgrades can no longer
  leave keys with stale-high limits.

The Studio chat path (`agent_usage`, `saveChatResult`) is untouched.
`saveChatResult` is narrowed to `'byoa'|'studio'` since `'api'` is
now invalid by construction and routes through `saveApiChatResult`.
@ABB65 ABB65 merged commit 3835ab4 into main May 15, 2026
1 check passed
@ABB65 ABB65 deleted the fix/conversation-api-billing-and-actor branch May 15, 2026 13:04
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