Skip to main content

Command Palette

Search for a command to run...

Tracking AI costs across providers: Anthropic and OpenAI Admin APIs in one Next.js page

Both expose usage and cost endpoints. Both have gotchas. Here's the wiring that actually worked.

Published
6 min read
Tracking AI costs across providers: Anthropic and OpenAI Admin APIs in one Next.js page

Anthropic's Admin API is invisible until you create an Organization. OpenAI's cost.amount.value can ship as a string. Anthropic renamed input_tokens to uncached_input_tokens and moved cache fields into a nested object. Each gotcha cost me about thirty minutes to figure out.

I was building a personal infra dashboard and wanted one section showing AI spend across providers. The endpoints exist on both Anthropic and OpenAI, both require Admin Keys (different from regular API keys), and both have response shapes that don't quite match the docs. This is the working integration, with every gotcha called out.

Why Admin Keys, not regular API keys

Both providers separate using the API from observing the organization that owns the API.

Regular API keys (sk-ant-..., sk-...) let you make completions and tool calls. They cannot see usage or cost data, even for their own requests. You need a separate Admin Key for that — sk-ant-admin-... for Anthropic, sk-admin-... for OpenAI.

This is a security boundary, not a billing thing. The intent is that a leaked completions key shouldn't expose your spend across the entire org.

Anthropic: Admin Keys hide behind Organizations

Here's the gotcha that took me the longest. From the Anthropic Admin API docs, in a tip box that's easy to skip:

The Admin API is unavailable for individual accounts.

If you signed up to Anthropic with a personal email and never converted to an Organization, the Admin Keys section literally does not appear in your Console. There's no error message, no upsell, no "Create Organization to unlock" button. The setting just doesn't exist.

The fix: Console → Settings → Organization → set up an organization. It's free. It doesn't change billing or add team-management features you don't want; it just unlocks the admin scope. After that, Settings → Admin Keys appears, and you can create sk-ant-admin-... keys.

Anthropic: the response shape changed

The endpoint is GET /v1/organizations/usage_report/messages. Auth is x-api-key: sk-ant-admin-..., plus the standard anthropic-version: 2023-06-01 header.

Older docs and blog posts I read pointed me at input_tokens and cache_creation_input_tokens as flat fields on each result. They aren't. The current shape is:

{
  "uncached_input_tokens": 1499,
  "cache_creation": {
    "ephemeral_1h_input_tokens": 0,
    "ephemeral_5m_input_tokens": 0
  },
  "cache_read_input_tokens": 0,
  "output_tokens": 83,
  "model": "claude-haiku-4-5-20251001"
}

If you sum input_tokens you get zero. The headline number in my dashboard was 0 input / 40K output for a week, which made no sense, and sent me back to the API to introspect.

Here's the working aggregation:

let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;

for (const bucket of usage) {
  for (const r of bucket.results) {
    const cacheCreate =
      (r.cache_creation?.ephemeral_5m_input_tokens ?? 0) +
      (r.cache_creation?.ephemeral_1h_input_tokens ?? 0);
    inputTokens += (r.uncached_input_tokens ?? 0) + cacheCreate + (r.cache_read_input_tokens ?? 0);
    outputTokens += r.output_tokens ?? 0;
    cacheReadTokens += r.cache_read_input_tokens ?? 0;
  }
}

I show inputTokens as the headline because it represents the full payload — base + cache write + cache read — not just the part that was billed at full rate.

OpenAI: Admin Keys are visible by default

Unlike Anthropic, OpenAI treats every account as an organization (a single-member one for individuals). Admin Keys are at platform.openai.com/settings/organization/admin-keys and appear without any setup. Click Create, copy the sk-admin-... value.

The endpoint is parallel to Anthropic's:

const start = Math.floor(Date.now() / 1000) - 7 * 86400;

const res = await fetch(
  `https://api.openai.com/v1/organization/usage/completions?start_time=${start}&bucket_width=1d&group_by=model`,
  { headers: { Authorization: `Bearer ${process.env.OPENAI_ADMIN_KEY}` } }
);

Two things differ from Anthropic:

  • OpenAI uses Unix timestamps (seconds since epoch), not ISO strings

  • OpenAI uses Authorization: Bearer, not x-api-key

Field names: input_tokens, output_tokens, input_cached_tokens, num_model_requests. Cleaner than Anthropic's, no nested objects.

OpenAI: cost.amount.value can ship as a string

The cost endpoint is GET /v1/organization/costs. The reference page documents amount.value as a number, and that's what most responses return:

{
  "amount": { "value": 0.0234, "currency": "usd" },
  "line_item": "..."
}

But I saw a string in actual responses on at least one query — possibly tied to a specific group_by or an older API version surfacing through. If you do totalCost += r.amount.value without parsing, a single string value flips the sum into concatenation: "0" + "0.0234" + "0.0091" = "00.02340.0091", and the reduce returns a string, the UI then renders as text.

I caught this only because my dashboard rendered a literal "00.02340.0091" instead of $0.0325. Defensive parsing prevents it regardless of which type the API ships:

const v = r.amount?.value;
costUsd += typeof v === 'string' ? parseFloat(v) : (v ?? 0);

The combined integration

Both providers wrap into a single function that returns a normalized card per provider:

export interface AiUsageCard {
  id: string;
  name: string;
  costUsd: number;
  inputTokens: number;
  outputTokens: number;
  dashboardUrl: string;
}

export async function getAllAiUsage(): Promise<AiUsageCard[]> {
  const results = await Promise.allSettled([
    getAnthropicUsage(7),
    getOpenAiUsage(7),
  ]);
  return results
    .map((r) => (r.status === 'fulfilled' ? r.value : null))
    .filter((v): v is AiUsageCard => v !== null);
}

Promise.allSettled is important — if one provider's API key is missing or expired, the other still renders. Each per-provider function returns null when its env var is unset, which keeps the card off the grid silently instead of showing a broken state.

Get the rest of the integration via email

That's enough to query both APIs and avoid the two response-shape gotchas. What's in the email series:

  • Day 0 — Anthropic's separate cost_report endpoint, plus the Priority Tier caveat that the docs don't make obvious

  • Day 2 — The full normalized-card integration: error handling, env-var fallbacks, and the type-safe aggregation that survives a missing key

  • Day 4 — The Gemini situation: why AI Studio keys can't be monitored at all, the Cloud Monitoring workaround for GCP-bound keys, and when it's worth it

  • Day 7 — Three things I built and would skip on a rewrite, plus the GitHub source

https://drippery.app/subscribe/d59f1e2e-d82f-4024-b450-366fbe75a428

The takeaway

Two clean takeaways for the public version:

  1. For Anthropic, set up an Organization first. The Admin Keys section is invisible without one.

  2. For OpenAI, parseFloat the cost amount. The string-vs-number mismatch will silently corrupt your sum.

The rest is in the email series above.