Skip to content

Commit fde49c7

Browse files
committed
Add web api endpoint for usage
1 parent 4768aa4 commit fde49c7

File tree

8 files changed

+229
-9
lines changed

8 files changed

+229
-9
lines changed

cli/src/commands/usage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BACKEND_URL } from '@codebuff/sdk'
1+
import { WEBSITE_URL } from '@codebuff/sdk'
22

33
import { useChatStore } from '../state/chat-store'
44
import { getAuthToken } from '../utils/auth'
@@ -30,7 +30,7 @@ export async function handleUsageCommand(): Promise<{
3030
}
3131

3232
try {
33-
const response = await fetch(`${BACKEND_URL}/api/usage`, {
33+
const response = await fetch(`${WEBSITE_URL}/api/v1/usage`, {
3434
method: 'POST',
3535
headers: {
3636
'Content-Type': 'application/json',

common/src/constants/analytics-events.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ export enum AnalyticsEvent {
102102
CHAT_COMPLETIONS_STREAM_STARTED = 'api.chat_completions_stream_started',
103103
CHAT_COMPLETIONS_ERROR = 'api.chat_completions_error',
104104

105+
// Web - Usage API
106+
USAGE_API_REQUEST = 'api.usage_request',
107+
USAGE_API_AUTH_ERROR = 'api.usage_auth_error',
108+
105109
// Web - Search API
106110
WEB_SEARCH_REQUEST = 'api.web_search_request',
107111
WEB_SEARCH_AUTH_ERROR = 'api.web_search_auth_error',

common/src/types/contracts/billing.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@ export type GetUserUsageDataFn = (params: {
55
userId: string
66
logger: Logger
77
}) => Promise<{
8-
balance: { totalRemaining: number }
8+
usageThisCycle: number
9+
balance: {
10+
totalRemaining: number
11+
totalDebt: number
12+
netBalance: number
13+
breakdown: Record<string, number>
14+
}
915
nextQuotaReset: string
16+
autoTopupTriggered?: boolean
1017
}>
1118

1219
export type ConsumeCreditsWithFallbackFn = (params: {
@@ -22,3 +29,15 @@ export type CreditFallbackResult = {
2229
organizationName?: string
2330
chargedToOrganization: boolean
2431
}
32+
33+
export type GetOrganizationUsageResponseFn = (params: {
34+
organizationId: string
35+
userId: string
36+
logger: Logger
37+
}) => Promise<{
38+
type: 'usage-response'
39+
usage: number
40+
remainingBalance: number
41+
balanceBreakdown: Record<string, never>
42+
next_quota_reset: null
43+
}>

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,24 @@ describe('/api/v1/chat/completions POST endpoint', () => {
6363
mockGetUserUsageData = mock(async ({ userId }: { userId: string }) => {
6464
if (userId === 'user-no-credits') {
6565
return {
66-
balance: { totalRemaining: 0 },
66+
usageThisCycle: 0,
67+
balance: {
68+
totalRemaining: 0,
69+
totalDebt: 0,
70+
netBalance: 0,
71+
breakdown: {},
72+
},
6773
nextQuotaReset: '2024-12-31',
6874
}
6975
}
7076
return {
71-
balance: { totalRemaining: 100 },
77+
usageThisCycle: 0,
78+
balance: {
79+
totalRemaining: 100,
80+
totalDebt: 0,
81+
netBalance: 100,
82+
breakdown: {},
83+
},
7284
nextQuotaReset: '2024-12-31',
7385
}
7486
})

web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ describe('/api/v1/docs-search POST endpoint', () => {
3434
mockTrackEvent = mock(() => {})
3535

3636
mockGetUserUsageData = mock(async () => ({
37-
balance: { totalRemaining: 10 },
37+
usageThisCycle: 0,
38+
balance: {
39+
totalRemaining: 10,
40+
totalDebt: 0,
41+
netBalance: 10,
42+
breakdown: {},
43+
},
3844
nextQuotaReset: 'soon',
3945
}))
4046
mockGetUserInfoFromApiKey = mock(async ({ apiKey }) =>
@@ -99,7 +105,13 @@ describe('/api/v1/docs-search POST endpoint', () => {
99105

100106
test('402 when insufficient credits', async () => {
101107
mockGetUserUsageData = mock(async () => ({
102-
balance: { totalRemaining: 0 },
108+
usageThisCycle: 0,
109+
balance: {
110+
totalRemaining: 0,
111+
totalDebt: 0,
112+
netBalance: 0,
113+
breakdown: {},
114+
},
103115
nextQuotaReset: 'soon',
104116
}))
105117
const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {

web/src/app/api/v1/usage/_post.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2+
import { INVALID_AUTH_TOKEN_MESSAGE } from '@codebuff/common/old-constants'
3+
import { NextResponse } from 'next/server'
4+
import { z } from 'zod/v4'
5+
6+
import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics'
7+
import type {
8+
GetUserInfoFromApiKeyFn,
9+
} from '@codebuff/common/types/contracts/database'
10+
import type { Logger } from '@codebuff/common/types/contracts/logger'
11+
import type { NextRequest } from 'next/server'
12+
13+
import type { GetOrganizationUsageResponseFn, GetUserUsageDataFn } from '@codebuff/common/types/contracts/billing'
14+
15+
const usageRequestSchema = z.object({
16+
fingerprintId: z.string(),
17+
authToken: z.string().optional(),
18+
orgId: z.string().optional(),
19+
})
20+
21+
export async function postUsage(params: {
22+
req: NextRequest
23+
getUserInfoFromApiKey: GetUserInfoFromApiKeyFn
24+
getUserUsageData: GetUserUsageDataFn
25+
getOrganizationUsageResponse: GetOrganizationUsageResponseFn
26+
trackEvent: TrackEventFn
27+
logger: Logger
28+
}) {
29+
const {
30+
req,
31+
getUserInfoFromApiKey,
32+
getUserUsageData,
33+
getOrganizationUsageResponse,
34+
trackEvent,
35+
logger,
36+
} = params
37+
38+
try {
39+
let body: unknown
40+
try {
41+
body = await req.json()
42+
} catch (error) {
43+
return NextResponse.json(
44+
{ message: 'Invalid JSON in request body' },
45+
{ status: 400 },
46+
)
47+
}
48+
49+
const parseResult = usageRequestSchema.safeParse(body)
50+
if (!parseResult.success) {
51+
return NextResponse.json(
52+
{ message: 'Invalid request body', issues: parseResult.error.issues },
53+
{ status: 400 },
54+
)
55+
}
56+
57+
const { fingerprintId, authToken, orgId } = parseResult.data
58+
59+
if (!authToken) {
60+
return NextResponse.json(
61+
{ message: 'Authentication required' },
62+
{ status: 401 },
63+
)
64+
}
65+
66+
const userInfo = await getUserInfoFromApiKey({
67+
apiKey: authToken,
68+
fields: ['id'],
69+
logger,
70+
})
71+
72+
if (!userInfo) {
73+
trackEvent({
74+
event: AnalyticsEvent.USAGE_API_AUTH_ERROR,
75+
userId: 'unknown',
76+
properties: {
77+
reason: 'Invalid API key',
78+
},
79+
logger,
80+
})
81+
return NextResponse.json(
82+
{ message: INVALID_AUTH_TOKEN_MESSAGE },
83+
{ status: 401 },
84+
)
85+
}
86+
87+
const userId = userInfo.id
88+
89+
trackEvent({
90+
event: AnalyticsEvent.USAGE_API_REQUEST,
91+
userId,
92+
properties: {
93+
fingerprintId,
94+
hasOrgId: !!orgId,
95+
},
96+
logger,
97+
})
98+
99+
// If orgId is provided, return organization usage data
100+
if (orgId) {
101+
try {
102+
const orgUsageResponse = await getOrganizationUsageResponse({
103+
organizationId: orgId,
104+
userId,
105+
logger,
106+
})
107+
return NextResponse.json(orgUsageResponse)
108+
} catch (error) {
109+
logger.error(
110+
{ error, orgId, userId },
111+
'Error fetching organization usage',
112+
)
113+
// If organization usage fails, fall back to personal usage
114+
logger.info(
115+
{ orgId, userId },
116+
'Falling back to personal usage due to organization error',
117+
)
118+
}
119+
}
120+
121+
// Return personal usage data (default behavior)
122+
const usageData = await getUserUsageData({ userId, logger })
123+
124+
// Format response to match backend API format
125+
const usageResponse = {
126+
type: 'usage-response' as const,
127+
usage: usageData.usageThisCycle,
128+
remainingBalance: usageData.balance.totalRemaining,
129+
balanceBreakdown: usageData.balance.breakdown,
130+
next_quota_reset: usageData.nextQuotaReset,
131+
}
132+
133+
return NextResponse.json(usageResponse)
134+
} catch (error) {
135+
logger.error({ error }, 'Error handling /api/v1/usage request')
136+
return NextResponse.json(
137+
{ error: 'Internal server error' },
138+
{ status: 500 },
139+
)
140+
}
141+
}

web/src/app/api/v1/usage/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getUserUsageData, getOrganizationUsageResponse } from '@codebuff/billing'
2+
import { trackEvent } from '@codebuff/common/analytics'
3+
4+
import { postUsage } from './_post'
5+
6+
import type { NextRequest } from 'next/server'
7+
8+
import { getUserInfoFromApiKey } from '@/db/user'
9+
import { logger } from '@/util/logger'
10+
11+
export async function POST(req: NextRequest) {
12+
return postUsage({
13+
req,
14+
getUserInfoFromApiKey,
15+
getUserUsageData,
16+
getOrganizationUsageResponse,
17+
trackEvent,
18+
logger,
19+
})
20+
}

web/src/app/api/v1/web-search/__tests__/web-search.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ describe('/api/v1/web-search POST endpoint', () => {
3434
mockTrackEvent = mock(() => {})
3535

3636
mockGetUserUsageData = mock(async () => ({
37-
balance: { totalRemaining: 10 },
37+
usageThisCycle: 0,
38+
balance: {
39+
totalRemaining: 10,
40+
totalDebt: 0,
41+
netBalance: 10,
42+
breakdown: {},
43+
},
3844
nextQuotaReset: 'soon',
3945
}))
4046
mockGetUserInfoFromApiKey = mock(async ({ apiKey }) =>
@@ -77,7 +83,13 @@ describe('/api/v1/web-search POST endpoint', () => {
7783

7884
test('402 when insufficient credits', async () => {
7985
mockGetUserUsageData = mock(async () => ({
80-
balance: { totalRemaining: 0 },
86+
usageThisCycle: 0,
87+
balance: {
88+
totalRemaining: 0,
89+
totalDebt: 0,
90+
netBalance: 0,
91+
breakdown: {},
92+
},
8193
nextQuotaReset: 'soon',
8294
}))
8395
const req = new NextRequest('http://localhost:3000/api/v1/web-search', {

0 commit comments

Comments
 (0)