Skip to content

Commit 95fedf0

Browse files
Backport: feat (provider/gateway): add spend reporting support (#13859)
This is an automated backport of #13841 to the release-v6.0 branch. FYI @shaper This backport has conflicts that need to be resolved manually. ### `git cherry-pick` output ``` Auto-merging content/providers/01-ai-sdk-providers/00-ai-gateway.mdx CONFLICT (content): Merge conflict in content/providers/01-ai-sdk-providers/00-ai-gateway.mdx Auto-merging packages/gateway/src/gateway-provider.ts error: could not apply d30466c... feat (provider/gateway): add spend reporting support (#13841) hint: After resolving the conflicts, mark them with hint: "git add/rm <pathspec>", then run hint: "git cherry-pick --continue". hint: You can instead skip this commit with "git cherry-pick --skip". hint: To abort and get back to the state before "git cherry-pick", hint: run "git cherry-pick --abort". hint: Disable this message with "git config set advice.mergeConflict false" ``` --------- Co-authored-by: Walter Korman <shaper@vercel.com>
1 parent 78384e9 commit 95fedf0

File tree

10 files changed

+973
-0
lines changed

10 files changed

+973
-0
lines changed

.changeset/light-dolphins-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ai-sdk/gateway": patch
3+
---
4+
5+
feat (provider/gateway): add spend reporting support

content/providers/01-ai-sdk-providers/00-ai-gateway.mdx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,77 @@ This allows you to:
559559
- Filter and analyze spending by feature or use case using tags
560560
- Track which users or features are driving the most AI usage
561561

562+
#### Querying Spend Reports
563+
564+
Use the `getSpendReport()` method to query usage data programmatically. The reporting API is only available for Vercel Pro and Enterprise plans. For pricing, see the [Custom Reporting docs](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting).
565+
566+
```ts
567+
import { gateway } from 'ai';
568+
569+
const report = await gateway.getSpendReport({
570+
startDate: '2026-03-01',
571+
endDate: '2026-03-25',
572+
groupBy: 'model',
573+
});
574+
575+
for (const row of report.results) {
576+
console.log(`${row.model}: $${row.totalCost.toFixed(4)}`);
577+
}
578+
```
579+
580+
The `getSpendReport()` method accepts the following parameters:
581+
582+
- **startDate** _string_ - Start date in `YYYY-MM-DD` format (inclusive, required)
583+
- **endDate** _string_ - End date in `YYYY-MM-DD` format (inclusive, required)
584+
- **groupBy** _string_ - Aggregation dimension: `'day'` (default), `'user'`, `'model'`, `'tag'`, `'provider'`, or `'credential_type'`
585+
- **datePart** _string_ - Time granularity when `groupBy` is `'day'`: `'day'` or `'hour'`
586+
- **userId** _string_ - Filter to a specific user
587+
- **model** _string_ - Filter to a specific model (e.g. `'anthropic/claude-sonnet-4.5'`)
588+
- **provider** _string_ - Filter to a specific provider (e.g. `'anthropic'`)
589+
- **credentialType** _string_ - Filter by `'byok'` or `'system'` credentials
590+
- **tags** _string[]_ - Filter to requests matching these tags
591+
592+
Each row in `results` contains a grouping field (matching your `groupBy` choice) and metrics:
593+
594+
- **totalCost** _number_ - Total cost in USD
595+
- **marketCost** _number_ - Market cost in USD
596+
- **inputTokens** _number_ - Number of input tokens
597+
- **outputTokens** _number_ - Number of output tokens
598+
- **cachedInputTokens** _number_ - Number of cached input tokens
599+
- **cacheCreationInputTokens** _number_ - Number of cache creation input tokens
600+
- **reasoningTokens** _number_ - Number of reasoning tokens
601+
- **requestCount** _number_ - Number of requests
602+
603+
You can combine tracking and querying to analyze spend by tags you defined:
604+
605+
```ts
606+
import type { GatewayProviderOptions } from '@ai-sdk/gateway';
607+
import { gateway, streamText } from 'ai';
608+
609+
// 1. Make requests with tags
610+
const result = streamText({
611+
model: gateway('anthropic/claude-haiku-4.5'),
612+
prompt: 'Summarize this quarter's results',
613+
providerOptions: {
614+
gateway: {
615+
tags: ['team:finance', 'feature:summaries'],
616+
} satisfies GatewayProviderOptions,
617+
},
618+
});
619+
620+
// 2. Later, query spend filtered by those tags
621+
const report = await gateway.getSpendReport({
622+
startDate: '2026-03-01',
623+
endDate: '2026-03-31',
624+
groupBy: 'tag',
625+
tags: ['team:finance'],
626+
});
627+
628+
for (const row of report.results) {
629+
console.log(`${row.tag}: $${row.totalCost.toFixed(4)} (${row.requestCount} requests)`);
630+
}
631+
```
632+
562633
## Provider Options
563634

564635
The AI Gateway provider accepts provider options that control routing behavior and provider-specific configurations.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { gateway } from 'ai';
2+
import { run } from '../lib/run';
3+
4+
// Queries spend for traffic generated by the stream-text-with-tags example,
5+
// combining multiple filters (model, tags, daily breakdown) in one call.
6+
// Run stream-text-with-tags.ts first to generate matching data.
7+
8+
run(async () => {
9+
const today = new Date().toISOString().split('T')[0];
10+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
11+
.toISOString()
12+
.split('T')[0];
13+
14+
// Filter to the exact model and tags used by stream-text-with-tags.ts
15+
console.log(
16+
`\n--- Filtered spend: claude-haiku-4.5 + reporting-test tag (${thirtyDaysAgo} to ${today}) ---\n`,
17+
);
18+
19+
const report = await gateway.getSpendReport({
20+
startDate: thirtyDaysAgo,
21+
endDate: today,
22+
datePart: 'day',
23+
model: 'anthropic/claude-haiku-4.5',
24+
tags: ['feature:reporting-test'],
25+
});
26+
27+
if (report.results.length === 0) {
28+
console.log(
29+
'No results found.',
30+
'Run stream-text-with-tags.ts first, then wait a few minutes for data to appear.',
31+
);
32+
return;
33+
}
34+
35+
for (const row of report.results) {
36+
console.log(
37+
[
38+
row.day,
39+
`$${row.totalCost.toFixed(4)} cost`,
40+
`${row.requestCount ?? 0} requests`,
41+
`${row.inputTokens ?? 0} in / ${row.outputTokens ?? 0} out tokens`,
42+
].join(' | '),
43+
);
44+
}
45+
46+
// Break down the same tagged traffic by provider to see routing
47+
console.log(`\n--- Same traffic grouped by provider ---\n`);
48+
49+
const byProvider = await gateway.getSpendReport({
50+
startDate: thirtyDaysAgo,
51+
endDate: today,
52+
groupBy: 'provider',
53+
model: 'anthropic/claude-haiku-4.5',
54+
tags: ['feature:reporting-test'],
55+
});
56+
57+
for (const row of byProvider.results) {
58+
console.log(
59+
`${row.provider}: $${row.totalCost.toFixed(4)} (${row.requestCount ?? 0} requests)`,
60+
);
61+
}
62+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { gateway } from 'ai';
2+
import { run } from '../lib/run';
3+
4+
run(async () => {
5+
const today = new Date().toISOString().split('T')[0];
6+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
7+
.toISOString()
8+
.split('T')[0];
9+
10+
// Query spend grouped by day for the last 30 days
11+
console.log(`\n--- Spend by day (${thirtyDaysAgo} to ${today}) ---\n`);
12+
const byDay = await gateway.getSpendReport({
13+
startDate: thirtyDaysAgo,
14+
endDate: today,
15+
});
16+
17+
for (const row of byDay.results) {
18+
console.log(
19+
`${row.day}: $${row.totalCost.toFixed(4)} (${row.requestCount ?? 0} requests)`,
20+
);
21+
}
22+
23+
// Query spend grouped by model
24+
console.log(`\n--- Spend by model ---\n`);
25+
const byModel = await gateway.getSpendReport({
26+
startDate: thirtyDaysAgo,
27+
endDate: today,
28+
groupBy: 'model',
29+
});
30+
31+
for (const row of byModel.results) {
32+
console.log(
33+
`${row.model}: $${row.totalCost.toFixed(4)} (${row.inputTokens ?? 0} in / ${row.outputTokens ?? 0} out)`,
34+
);
35+
}
36+
37+
// Query spend filtered by the tags written by stream-text-with-tags example
38+
console.log(`\n--- Spend filtered by tag "feature:reporting-test" ---\n`);
39+
const byTag = await gateway.getSpendReport({
40+
startDate: thirtyDaysAgo,
41+
endDate: today,
42+
groupBy: 'tag',
43+
tags: ['feature:reporting-test'],
44+
});
45+
46+
if (byTag.results.length === 0) {
47+
console.log(
48+
'No results. Run stream-text-with-tags.ts first to generate tagged data.',
49+
);
50+
}
51+
52+
for (const row of byTag.results) {
53+
console.log(
54+
`${row.tag}: $${row.totalCost.toFixed(4)} (${row.requestCount ?? 0} requests)`,
55+
);
56+
}
57+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { GatewayProviderOptions } from '@ai-sdk/gateway';
2+
import { streamText } from 'ai';
3+
import { run } from '../lib/run';
4+
5+
run(async () => {
6+
const result = streamText({
7+
model: 'anthropic/claude-haiku-4.5',
8+
prompt: 'What are three interesting facts about honeybees?',
9+
providerOptions: {
10+
gateway: {
11+
tags: ['team:examples', 'feature:reporting-test', 'env:development'],
12+
} satisfies GatewayProviderOptions,
13+
},
14+
});
15+
16+
for await (const text of result.textStream) {
17+
process.stdout.write(text);
18+
}
19+
20+
console.log('\n');
21+
console.log('Token usage:', await result.usage);
22+
console.log('Finish reason:', await result.finishReason);
23+
console.log(
24+
'Provider metadata:',
25+
JSON.stringify(await result.providerMetadata, null, 2),
26+
);
27+
});

packages/gateway/src/gateway-provider.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getGatewayAuthToken,
66
} from './gateway-provider';
77
import { GatewayFetchMetadata } from './gateway-fetch-metadata';
8+
import { GatewaySpendReport } from './gateway-spend-report';
89
import { NoSuchModelError } from '@ai-sdk/provider';
910
import { GatewayEmbeddingModel } from './gateway-embedding-model';
1011
import { GatewayImageModel } from './gateway-image-model';
@@ -22,6 +23,20 @@ vi.mock('./gateway-language-model', () => ({
2223
GatewayLanguageModel: vi.fn(function () {}),
2324
}));
2425

26+
const mockGetSpendReport = vi.fn();
27+
vi.mock('./gateway-spend-report', () => ({
28+
GatewaySpendReport: vi.fn(function (config: any) {
29+
return {
30+
getSpendReport: async (params: any) => {
31+
if (config.headers && typeof config.headers === 'function') {
32+
await config.headers();
33+
}
34+
return mockGetSpendReport(params);
35+
},
36+
};
37+
}),
38+
}));
39+
2540
// Mock the gateway fetch metadata to prevent actual network calls
2641
// We'll create a more flexible mock that can simulate auth failures
2742
const mockGetAvailableModels = vi.fn();
@@ -126,6 +141,7 @@ describe('GatewayProvider', () => {
126141
// Set up default mock behavior for getAvailableModels and getCredits
127142
mockGetAvailableModels.mockReturnValue({ models: [] });
128143
mockGetCredits.mockReturnValue({ balance: '100.00', total_used: '50.00' });
144+
mockGetSpendReport.mockReturnValue({ results: [] });
129145
if ('AI_GATEWAY_API_KEY' in process.env) {
130146
Reflect.deleteProperty(process.env, 'AI_GATEWAY_API_KEY');
131147
}
@@ -1150,6 +1166,122 @@ describe('GatewayProvider', () => {
11501166
});
11511167
});
11521168

1169+
describe('getSpendReport method', () => {
1170+
it('should fetch spend report successfully', async () => {
1171+
const mockResults = {
1172+
results: [{ day: '2026-03-01', totalCost: 10.5, requestCount: 25 }],
1173+
};
1174+
mockGetSpendReport.mockReturnValue(mockResults);
1175+
1176+
const provider = createGatewayProvider({
1177+
apiKey: 'test-key',
1178+
});
1179+
1180+
const report = await provider.getSpendReport({
1181+
startDate: '2026-03-01',
1182+
endDate: '2026-03-25',
1183+
});
1184+
1185+
expect(report).toEqual(mockResults);
1186+
expect(GatewaySpendReport).toHaveBeenCalledWith(
1187+
expect.objectContaining({
1188+
baseURL: 'https://ai-gateway.vercel.sh/v3/ai',
1189+
headers: expect.any(Function),
1190+
fetch: undefined,
1191+
}),
1192+
);
1193+
});
1194+
1195+
it('should pass params through to GatewaySpendReport', async () => {
1196+
const provider = createGatewayProvider({
1197+
apiKey: 'test-key',
1198+
});
1199+
1200+
await provider.getSpendReport({
1201+
startDate: '2026-03-01',
1202+
endDate: '2026-03-25',
1203+
groupBy: 'model',
1204+
datePart: 'day',
1205+
userId: 'user-123',
1206+
model: 'anthropic/claude-sonnet-4.6',
1207+
tags: ['production', 'api'],
1208+
});
1209+
1210+
expect(mockGetSpendReport).toHaveBeenCalledWith({
1211+
startDate: '2026-03-01',
1212+
endDate: '2026-03-25',
1213+
groupBy: 'model',
1214+
datePart: 'day',
1215+
userId: 'user-123',
1216+
model: 'anthropic/claude-sonnet-4.6',
1217+
tags: ['production', 'api'],
1218+
});
1219+
});
1220+
1221+
it('should work with custom baseURL', async () => {
1222+
const customBaseURL = 'https://custom-gateway.example.com/v3/ai';
1223+
const provider = createGatewayProvider({
1224+
apiKey: 'test-key',
1225+
baseURL: customBaseURL,
1226+
});
1227+
1228+
await provider.getSpendReport({
1229+
startDate: '2026-03-01',
1230+
endDate: '2026-03-25',
1231+
});
1232+
1233+
expect(GatewaySpendReport).toHaveBeenCalledWith(
1234+
expect.objectContaining({
1235+
baseURL: customBaseURL,
1236+
}),
1237+
);
1238+
});
1239+
1240+
it('should work with custom fetch function', async () => {
1241+
const customFetch = vi.fn();
1242+
const provider = createGatewayProvider({
1243+
apiKey: 'test-key',
1244+
fetch: customFetch,
1245+
});
1246+
1247+
await provider.getSpendReport({
1248+
startDate: '2026-03-01',
1249+
endDate: '2026-03-25',
1250+
});
1251+
1252+
expect(GatewaySpendReport).toHaveBeenCalledWith(
1253+
expect.objectContaining({
1254+
fetch: customFetch,
1255+
}),
1256+
);
1257+
});
1258+
1259+
it('should handle errors from the spend report endpoint', async () => {
1260+
const testError = new Error('Reporting service unavailable');
1261+
mockGetSpendReport.mockRejectedValue(testError);
1262+
1263+
const provider = createGatewayProvider({
1264+
apiKey: 'test-key',
1265+
});
1266+
1267+
await expect(
1268+
provider.getSpendReport({
1269+
startDate: '2026-03-01',
1270+
endDate: '2026-03-25',
1271+
}),
1272+
).rejects.toThrow('Reporting service unavailable');
1273+
});
1274+
1275+
it('should be available on the provider interface', () => {
1276+
const provider = createGatewayProvider({ apiKey: 'test-key' });
1277+
expect(typeof provider.getSpendReport).toBe('function');
1278+
});
1279+
1280+
it('should be available on the default gateway export', () => {
1281+
expect(typeof gateway.getSpendReport).toBe('function');
1282+
});
1283+
});
1284+
11531285
describe('Error handling in metadata fetching', () => {
11541286
it('should convert metadata fetch errors to Gateway errors', async () => {
11551287
mockGetAvailableModels.mockImplementation(() => {

0 commit comments

Comments
 (0)