Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 143 additions & 12 deletions packages/web/src/app/api/admin/__tests__/admin-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,106 @@ describe("GET /api/admin/cost", () => {
expect(body).not.toHaveProperty("stack");
});

it("proxies 200 response from upstream when key is present (admin)", async () => {
it("sends starting_at (RFC 3339) and bucket_width to the upstream API", async () => {
process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key";
const fakePayload = { total_cost: 42.5, currency: "USD" };
stubFetch({ ok: true, status: 200, json: async () => fakePayload });
const fetchMock = vi.fn<(url: string) => Promise<unknown>>(async () => ({
ok: true,
status: 200,
json: async () => ({ data: [], has_more: false, next_page: null }),
text: async () => "",
}));
vi.stubGlobal("fetch", fetchMock);

const res = await costRoute(adminReq("/api/admin/cost?days=7"));
expect(res.status).toBe(200);
const calledUrl = new URL(fetchMock.mock.calls[0]![0]);
expect(calledUrl.pathname).toBe("/v1/organizations/cost_report");
const startingAt = calledUrl.searchParams.get("starting_at")!;
// RFC 3339 timestamp snapped to UTC midnight; the old start_date/end_date
// params were unknown to the API and made every request 400.
expect(startingAt).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00/);
expect(calledUrl.searchParams.get("bucket_width")).toBe("1d");
expect(calledUrl.searchParams.has("start_date")).toBe(false);
});

it("aggregates cent-string amounts into a normalized USD summary (admin)", async () => {
process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key";
// Real cost_report shape: daily buckets, amounts as decimal strings in CENTS.
const report = {
data: [
{
starting_at: "2026-07-01T00:00:00Z",
ending_at: "2026-07-02T00:00:00Z",
results: [
{ amount: "123.78912", currency: "USD" },
{ amount: "76.21088", currency: "USD" },
],
},
{
starting_at: "2026-07-02T00:00:00Z",
ending_at: "2026-07-03T00:00:00Z",
results: [{ amount: "300", currency: "USD" }],
},
],
has_more: false,
next_page: null,
};
stubFetch({ ok: true, status: 200, json: async () => report });

const res = await costRoute(adminReq("/api/admin/cost"));
expect(res.status).toBe(200);
const body = (await jsonOf(res)) as {
totalCostUsd: number;
daily: Array<{ date: string; costUsd: number }>;
truncated: boolean;
};
// 200 cents + 300 cents = $5.00
expect(body.totalCostUsd).toBeCloseTo(5, 6);
expect(body.daily).toEqual([
{ date: "2026-07-01", costUsd: 2 },
{ date: "2026-07-02", costUsd: 3 },
]);
expect(body.truncated).toBe(false);
});

it("follows has_more/next_page pagination (admin)", async () => {
process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key";
const page1 = {
data: [
{
starting_at: "2026-07-01T00:00:00Z",
ending_at: "2026-07-02T00:00:00Z",
results: [{ amount: "100", currency: "USD" }],
},
],
has_more: true,
next_page: "page_token_2",
};
const page2 = {
data: [
{
starting_at: "2026-07-02T00:00:00Z",
ending_at: "2026-07-03T00:00:00Z",
results: [{ amount: "100", currency: "USD" }],
},
],
has_more: false,
next_page: null,
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => page1 })
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => page2 });
vi.stubGlobal("fetch", fetchMock);

const res = await costRoute(adminReq("/api/admin/cost"));
expect(res.status).toBe(200);
const body = (await jsonOf(res)) as typeof fakePayload;
expect(body.total_cost).toBe(42.5);
expect(body.currency).toBe("USD");
expect(fetchMock).toHaveBeenCalledTimes(2);
const secondUrl = new URL(fetchMock.mock.calls[1]![0] as string);
expect(secondUrl.searchParams.get("page")).toBe("page_token_2");
const body = (await jsonOf(res)) as { totalCostUsd: number; daily: unknown[] };
expect(body.totalCostUsd).toBeCloseTo(2, 6); // 100c + 100c
expect(body.daily).toHaveLength(2);
});

it("surfaces upstream 401 as same status code (admin)", async () => {
Expand All @@ -169,16 +259,57 @@ describe("GET /api/admin/analytics", () => {
expect(body).not.toHaveProperty("stack");
});

it("proxies 200 response from upstream when key is present (admin)", async () => {
it("normalizes token totals from the usage report shape (admin)", async () => {
process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key";
const fakePayload = { data: [{ model: "claude-3-5-sonnet", tokens: 1000 }] };
stubFetch({ ok: true, status: 200, json: async () => fakePayload });
// Real usage_report/messages shape.
const report = {
data: [
{
starting_at: "2026-07-01T00:00:00Z",
ending_at: "2026-07-02T00:00:00Z",
results: [
{
uncached_input_tokens: 1500,
cache_read_input_tokens: 200,
cache_creation: {
ephemeral_5m_input_tokens: 500,
ephemeral_1h_input_tokens: 1000,
},
output_tokens: 500,
server_tool_use: { web_search_requests: 10 },
},
{
uncached_input_tokens: 500,
cache_read_input_tokens: 100,
cache_creation: {
ephemeral_5m_input_tokens: 0,
ephemeral_1h_input_tokens: 0,
},
output_tokens: 250,
server_tool_use: { web_search_requests: 0 },
},
],
},
],
has_more: false,
next_page: null,
};
stubFetch({ ok: true, status: 200, json: async () => report });

const res = await analyticsRoute(adminReq("/api/admin/analytics"));
expect(res.status).toBe(200);
const body = (await jsonOf(res)) as typeof fakePayload;
expect(body.data).toHaveLength(1);
expect(body.data[0]?.model).toBe("claude-3-5-sonnet");
const body = (await jsonOf(res)) as {
uncachedInputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
outputTokens: number;
webSearchRequests: number;
};
expect(body.uncachedInputTokens).toBe(2000);
expect(body.cacheReadInputTokens).toBe(300);
expect(body.cacheCreationInputTokens).toBe(1500);
expect(body.outputTokens).toBe(750);
expect(body.webSearchRequests).toBe(10);
});

it("surfaces upstream 500 as same status code (admin)", async () => {
Expand Down
145 changes: 117 additions & 28 deletions packages/web/src/app/api/admin/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,59 @@ import { NextResponse, type NextRequest } from "next/server";
import { requireAdmin } from "@/lib/auth";
export const dynamic = "force-dynamic";

// The Anthropic Admin usage_report/messages endpoint:
// - requires `starting_at` (RFC 3339); `start_date`/`end_date` are not
// parameters it knows about
// - returns time buckets whose results carry uncached_input_tokens,
// cache_read_input_tokens, cache_creation.{ephemeral_5m,1h}_input_tokens,
// output_tokens, and server_tool_use.web_search_requests
// - paginates via has_more/next_page
// We normalize into org-wide token totals for the dashboard.

const MAX_DAYS = 31;
const DEFAULT_DAYS = 30;
const MAX_PAGES = 8;

interface UsageResult {
uncached_input_tokens?: number;
cache_read_input_tokens?: number;
cache_creation?: {
ephemeral_5m_input_tokens?: number;
ephemeral_1h_input_tokens?: number;
};
output_tokens?: number;
server_tool_use?: { web_search_requests?: number };
}

interface UsageBucket {
starting_at: string;
ending_at: string;
results: UsageResult[];
}

interface UsageReportPage {
data: UsageBucket[];
has_more: boolean;
next_page: string | null;
}

export interface AdminUsageSummary {
uncachedInputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
outputTokens: number;
webSearchRequests: number;
days: number;
truncated: boolean;
}

function windowStart(days: number): string {
const start = new Date();
start.setUTCHours(0, 0, 0, 0);
start.setUTCDate(start.getUTCDate() - (days - 1));
return start.toISOString();
}

export async function GET(request: NextRequest) {
const user = await requireAdmin(request);
if (user instanceof NextResponse) return user;
Expand All @@ -16,37 +69,73 @@ export async function GET(request: NextRequest) {

try {
const { searchParams } = new URL(request.url);
const startDate = searchParams.get("start_date");
const endDate = searchParams.get("end_date");

const params = new URLSearchParams();
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);

const queryString = params.toString();
const url = `https://api.anthropic.com/v1/organizations/usage_report/messages${queryString ? `?${queryString}` : ""}`;

const response = await fetch(url, {
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorText = await response.text();
console.error(
`[admin/analytics] Anthropic API ${response.status}: ${errorText.slice(0, 500)}`,
);
return NextResponse.json(
{ error: `Anthropic API error: ${response.status}` },
{ status: response.status },
const daysRaw = Number(searchParams.get("days") ?? DEFAULT_DAYS);
const days = Number.isFinite(daysRaw)
? Math.min(Math.max(Math.trunc(daysRaw), 1), MAX_DAYS)
: DEFAULT_DAYS;

const buckets: UsageBucket[] = [];
let page: string | null = null;
let pagesLeft = MAX_PAGES;

do {
const params = new URLSearchParams({
starting_at: windowStart(days),
bucket_width: "1d",
limit: String(MAX_DAYS),
});
if (page) params.set("page", page);

const response = await fetch(
`https://api.anthropic.com/v1/organizations/usage_report/messages?${params.toString()}`,
{
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
},
);

if (!response.ok) {
const errorText = await response.text();
console.error(
`[admin/analytics] Anthropic API ${response.status}: ${errorText.slice(0, 500)}`,
);
return NextResponse.json(
{ error: `Anthropic API error: ${response.status}` },
{ status: response.status },
);
}

const json = (await response.json()) as UsageReportPage;
buckets.push(...(json.data ?? []));
page = json.has_more && json.next_page ? json.next_page : null;
pagesLeft -= 1;
} while (page && pagesLeft > 0);

const summary: AdminUsageSummary = {
uncachedInputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 0,
webSearchRequests: 0,
days,
truncated: page !== null,
};
for (const bucket of buckets) {
for (const result of bucket.results ?? []) {
summary.uncachedInputTokens += result.uncached_input_tokens ?? 0;
summary.cacheReadInputTokens += result.cache_read_input_tokens ?? 0;
summary.cacheCreationInputTokens +=
(result.cache_creation?.ephemeral_5m_input_tokens ?? 0) +
(result.cache_creation?.ephemeral_1h_input_tokens ?? 0);
summary.outputTokens += result.output_tokens ?? 0;
summary.webSearchRequests +=
result.server_tool_use?.web_search_requests ?? 0;
}
}

const data = await response.json();
return NextResponse.json(data);
return NextResponse.json(summary);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
Expand Down
Loading
Loading