feat(usage-api): make external endpoint to query usage (#1285)

* feat(usage-api): make external endpoint to query usage

* add docs

* consolidate endpoints with rate-limits one

* update docs

* consolidate code

* remove unused route
This commit is contained in:
Vikhyath Mondreti
2025-09-08 16:35:58 -07:00
committed by GitHub
parent 5218dd41b9
commit d357280003
5 changed files with 162 additions and 82 deletions

View File

@@ -212,3 +212,47 @@ Monitor your usage and billing in Settings → Subscription:
- **Usage Limits**: Plan limits with visual progress indicators
- **Billing Details**: Projected charges and minimum commitments
- **Plan Management**: Upgrade options and billing history
### Programmatic Rate Limits & Usage (API)
You can query your current API rate limits and usage summary using your API key.
Endpoint:
```text
GET /api/users/me/usage-limits
```
Authentication:
- Include your API key in the `X-API-Key` header.
Response (example):
```json
{
"success": true,
"rateLimit": {
"sync": { "isLimited": false, "limit": 10, "remaining": 10, "resetAt": "2025-09-08T22:51:55.999Z" },
"async": { "isLimited": false, "limit": 50, "remaining": 50, "resetAt": "2025-09-08T22:51:56.155Z" },
"authType": "api"
},
"usage": {
"currentPeriodCost": 12.34,
"limit": 100,
"plan": "pro"
}
}
```
Example:
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
```
Notes:
- `currentPeriodCost` reflects usage in the current billing period.
- `limit` is derived from individual limits (Free/Pro) or pooled organization limits (Team/Enterprise).
- `plan` is the highest-priority active plan associated with your user.

View File

@@ -1,79 +0,0 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { apiKey as apiKeyTable } from '@/db/schema'
import { RateLimiter } from '@/services/queue'
const logger = createLogger('RateLimitAPI')
export async function GET(request: NextRequest) {
try {
const session = await getSession()
let authenticatedUserId: string | null = session?.user?.id || null
if (!authenticatedUserId) {
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
const [apiKeyRecord] = await db
.select({ userId: apiKeyTable.userId })
.from(apiKeyTable)
.where(eq(apiKeyTable.key, apiKeyHeader))
.limit(1)
if (apiKeyRecord) {
authenticatedUserId = apiKeyRecord.userId
}
}
}
if (!authenticatedUserId) {
return createErrorResponse('Authentication required', 401)
}
// Get user subscription (checks both personal and org subscriptions)
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
const rateLimiter = new RateLimiter()
const isApiAuth = !session?.user?.id
const triggerType = isApiAuth ? 'api' : 'manual'
const syncStatus = await rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
false
)
const asyncStatus = await rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
true
)
return NextResponse.json({
success: true,
rateLimit: {
sync: {
isLimited: syncStatus.remaining === 0,
limit: syncStatus.limit,
remaining: syncStatus.remaining,
resetAt: syncStatus.resetAt,
},
async: {
isLimited: asyncStatus.remaining === 0,
limit: asyncStatus.limit,
remaining: asyncStatus.remaining,
resetAt: asyncStatus.resetAt,
},
authType: triggerType,
},
})
} catch (error: any) {
logger.error('Error checking rate limit:', error)
return createErrorResponse(error.message || 'Failed to check rate limit', 500)
}
}

View File

@@ -0,0 +1,74 @@
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { RateLimiter } from '@/services/queue'
const logger = createLogger('UsageLimitsAPI')
export async function GET(request: NextRequest) {
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return createErrorResponse('Authentication required', 401)
}
const authenticatedUserId = auth.userId
// Rate limit info (sync + async), mirroring /users/me/rate-limit
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
const rateLimiter = new RateLimiter()
const triggerType = auth.authType === 'api_key' ? 'api' : 'manual'
const [syncStatus, asyncStatus] = await Promise.all([
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
false
),
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,
userSubscription,
triggerType,
true
),
])
// Usage summary (current period cost + limit + plan)
const [usageCheck, effectiveCost] = await Promise.all([
checkServerSideUsageLimits(authenticatedUserId),
getEffectiveCurrentPeriodCost(authenticatedUserId),
])
const currentPeriodCost = effectiveCost
return NextResponse.json({
success: true,
rateLimit: {
sync: {
isLimited: syncStatus.remaining === 0,
limit: syncStatus.limit,
remaining: syncStatus.remaining,
resetAt: syncStatus.resetAt,
},
async: {
isLimited: asyncStatus.remaining === 0,
limit: asyncStatus.limit,
remaining: asyncStatus.remaining,
resetAt: asyncStatus.resetAt,
},
authType: triggerType,
},
usage: {
currentPeriodCost,
limit: usageCheck.limit,
plan: userSubscription?.plan || 'free',
},
})
} catch (error: any) {
logger.error('Error checking usage limits:', error)
return createErrorResponse(error.message || 'Failed to check usage limits', 500)
}
}

View File

@@ -79,7 +79,7 @@ export function ExampleCommand({
case 'rate-limits': {
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
return `curl -H "X-API-Key: ${apiKey}" \\
${baseUrlForRateLimit}/api/users/me/rate-limit`
${baseUrlForRateLimit}/api/users/me/usage-limits`
}
default:
@@ -119,7 +119,7 @@ export function ExampleCommand({
case 'rate-limits': {
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
return `curl -H "X-API-Key: SIM_API_KEY" \\
${baseUrlForRateLimit}/api/users/me/rate-limit`
${baseUrlForRateLimit}/api/users/me/usage-limits`
}
default:

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { eq, inArray } from 'drizzle-orm'
import { getEmailSubject, renderUsageThresholdEmail } from '@/components/emails/render-email'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
@@ -490,6 +490,47 @@ export async function getTeamUsageLimits(organizationId: string): Promise<
}
}
/**
* Returns the effective current period usage cost for a user.
* - Free/Pro: user's own currentPeriodCost (fallback to totalCost)
* - Team/Enterprise: pooled sum of all members' currentPeriodCost within the organization
*/
export async function getEffectiveCurrentPeriodCost(userId: string): Promise<number> {
const subscription = await getHighestPrioritySubscription(userId)
// If no team/org subscription, return the user's own usage
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (rows.length === 0) return 0
return rows[0].current ? Number.parseFloat(rows[0].current.toString()) : 0
}
// Team/Enterprise: pooled usage across org members
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, subscription.referenceId))
if (teamMembers.length === 0) return 0
const memberIds = teamMembers.map((m) => m.userId)
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(inArray(userStats.userId, memberIds))
let pooled = 0
for (const r of rows) {
pooled += r.current ? Number.parseFloat(r.current.toString()) : 0
}
return pooled
}
/**
* Calculate billing projection based on current usage
*/