mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
5218dd41b9
commit
d357280003
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
74
apps/sim/app/api/users/me/usage-limits/route.ts
Normal file
74
apps/sim/app/api/users/me/usage-limits/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user