Skip to content

Diagnostic LLM APIs

audio and image usage never shows up in a token dashboard

The internal dashboard has been within a few percent of the invoice for a year, and a few percent is what everyone expects a dashboard to be. It is not rounding. It is the text-to-speech in the mobile app, the transcription on the support calls, the thumbnails the marketing tool generates, and the web search the agent does before it answers. None of those are denominated in tokens, and the endpoint the dashboard was built on only knows about tokens.

Read-only key Python and Node.js Tests included
Red and white love print textile
Photo by Tamanna Rumee on Unsplash
The short answer

Stop treating /v1/organization/usage/completions as the organization's spend and start treating GET /v1/organization/costs?start_time=…&group_by=line_item as it. Costs are in dollars and cover everything; usage is in whatever unit that surface happens to bill in.

The Usage API is split by modality on purpose, because the units differ. Speech bills by characters, transcription by seconds, images by images, code interpreter by num_sessions, and file search and web search by num_requests. Eight endpoints, five units, and a token dashboard can see none of them.

There is a second, quieter half of this. Multimodal chat sends audio and images through the completions endpoint, where they arrive as input_audio_tokens, output_audio_tokens and input_image_tokens alongside input_text_tokens. A dashboard adding input_tokens + output_tokens is silently mixing token types that are priced differently.

The problem in plain words

A discrepancy that is small and stable is the hardest kind to investigate, because it never gets worse quickly enough to be anybody's problem this week. It gets rationalised. Rounding, timing, the cost report lagging real time, currency conversion — there are four plausible explanations for a three percent gap and all of them are wrong.

Then the product ships a voice feature, or an agent that searches the web before answering, and the gap stops being three percent. Nobody notices the moment it changes, because the dashboard the team looks at every morning cannot render the thing that changed. The first signal is the invoice, and by then the question is not "why is this line here" but "how long has this line been here", which is a much worse conversation.

Web search in particular is priced per thousand calls rather than per token, so an agent that searches twice per turn generates a line item that scales with conversations and appears nowhere in a token graph at all.

Dashboard readscompletionstokens in, tokensoutSpeech billscharactersdifferent endpointSearch billsper callno tokens at allTotals drift afew percentfiled underroundingInvoice arriveshow long has thisbeen here
A gap of three percent has four plausible innocent explanations, and all four are wrong. Then a voice feature ships.

Why it happens

The units are genuinely different, so the endpoints have to be. There is no honest way to express seconds of audio as tokens, and OpenAI does not pretend otherwise: each modality gets its own path under /v1/organization/usage/, its own result object type, and its own quantity field. A script written against one of them is structurally incapable of seeing the others. That is not a bug to work around, it is the shape of the API.

Costs is the only endpoint denominated in money. GET /v1/organization/costs returns amount.value in a currency, grouped by line_item, and it is the one place where audio, images, tools and tokens are commensurable. Anything that claims to be a spend dashboard and is not driven by this endpoint is a usage dashboard wearing a dollar sign.

Usage explains, costs totals. The right division of labour is the opposite of the common one: read the money from costs, and reach for the per-modality usage endpoints only to answer why a line item moved. Built the other way round, the dashboard is both incomplete and slower to explain itself.

Multimodal tokens inside completions are a separate hazard. The completions result carries input_text_tokens, input_audio_tokens, input_image_tokens, output_audio_tokens and friends alongside the totals. Summing the totals treats a text token and an audio token as the same money, and they are not.

An unrecognised line item is a finding, not an error. The set of billable surfaces changes when the platform ships things. A reconciliation that only knows the line items it was written against will silently drop the next one, so a line item the script cannot classify is reported loudly rather than bucketed into "other" and forgotten.

The fix, as a flow

One endpoint is denominated in money and eight are denominated in characters, seconds, images, sessions and calls. The reconciliation runs in that direction on purpose: costs grouped by line item is the total, and the per modality endpoints are only there to explain what moved.

Costs by line itemminus what you coverAudio, image, tool spendinvisible in a token graphLine items nobody can nameread the strings firstGap under the tolerancerounding and report lagAudio tokens inside chatpriced apart from text
A line item the script cannot classify is reported loudly. The platform ships new billable surfaces, and a quiet other bucket swallows the next one.

How to fix it

Pull the money first

GET /v1/organization/costs?start_time={now-30d}&limit=31&group_by=line_item. Sum results[].amount.value per line_item. This is the denominator for everything that follows, and it is the number the invoice will agree with.

Declare what your dashboard actually covers

Not what it aspires to cover. If it reads completions and nothing else, it covers text tokens. Pass that as an argument, and let the script subtract: the gap is the part of the bill your team has never seen rendered.

Sweep the modality endpoints for the volume behind each line

audio_speeches (characters), audio_transcriptions (seconds), images (images, groupable by size and source), code_interpreter_sessions (num_sessions), file_search_calls and web_search_calls (num_requests), plus embeddings and moderations. Same window, same bucket_width=1d. These do not tell you the money; they tell you what the money was for.

Check the token types inside completions too

Read input_text_tokens, input_audio_tokens and input_image_tokens separately rather than taking input_tokens whole. Non-zero audio or image token counts mean multimodal traffic is flowing through chat and your naive sum is mispricing it.

Report the gap in dollars and name the lines

A percentage is arguable and a list of line items with amounts is not. Print each uncovered line_item with its amount.value, and its quantity and quantity_unit where the report carries them, so the reader can see both the money and what was bought with it.

How to check it worked

Re-run after the dashboard is rebuilt on costs. The uncovered share should fall inside the tolerance and stay there when a new surface ships.

python3 openai_modality_spend_reconcile.py --covers text
# gap          $18,402.11 total, $2,914.68 (15.8%) outside what the dashboard covers
#   uncovered  audio      $1,802.40   Text-to-speech        14,209,881 characters
#   uncovered  tool       $  784.00   Web search            78,400 requests
#   uncovered  image      $  328.28   Image generation      6,120 images
# 1 finding(s)

The full code

One costs call for the money and eight usage calls for the volume behind it, all GET, all needing OPENAI_ADMIN_KEY. The judgement is in three pure functions: mapping a line_item string onto a modality family, subtracting what your dashboard covers from the total, and deciding whether the remainder is rounding or a hole. A fourth reads the token types hiding inside a completions result, which is the same problem one level down.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 LLM API fixes, free and open source.
openai_modality_spend_reconcile.py
"""Reconcile an OpenAI token dashboard against the whole bill.

Read only. GET requests and nothing else: OPENAI_ADMIN_KEY must be an
organization admin key (sk-admin-...) with read scopes.

Costs is the only endpoint denominated in money. The per-modality usage
endpoints are denominated in characters, seconds, images, sessions and calls,
and a dashboard built on completions can see none of them. This script prints
the difference and stops.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_modality_spend_reconcile")

API = "https://api.openai.com/v1"

# Every usage surface, with the field it is denominated in. Five different units
# across eight endpoints is the reason a token dashboard cannot be made complete
# by adding one more query to it.
SURFACES = (
    ("completions", "/organization/usage/completions", "num_model_requests", "requests"),
    ("embeddings", "/organization/usage/embeddings", "input_tokens", "tokens"),
    ("moderations", "/organization/usage/moderations", "input_tokens", "tokens"),
    ("audio_speeches", "/organization/usage/audio_speeches", "characters", "characters"),
    ("audio_transcriptions", "/organization/usage/audio_transcriptions", "seconds", "seconds"),
    ("images", "/organization/usage/images", "images", "images"),
    ("code_interpreter_sessions", "/organization/usage/code_interpreter_sessions",
     "num_sessions", "sessions"),
    ("file_search_calls", "/organization/usage/file_search_calls", "num_requests", "calls"),
    ("web_search_calls", "/organization/usage/web_search_calls", "num_requests", "calls"),
)

# Matched in order against a lowercased line_item. Audio, image and tool come
# before text because "gpt-image-1" and "gpt-4o-audio-preview" both contain a
# text-model substring and neither is billed in text tokens.
FAMILIES = (
    ("audio", ("audio", "speech", "transcription", "whisper", "tts", "realtime")),
    ("image", ("image", "dall-e")),
    ("tool", ("web search", "web_search", "file search", "file_search",
              "code interpreter", "code_interpreter", "container")),
    ("embedding", ("embedding",)),
    ("moderation", ("moderation",)),
    ("text", ("input tokens", "output tokens", "cached input", "cached_input",
              "gpt-", "o1-", "o3", "o4-", "chat")),
)

# The token types that hide inside a completions result. Adding input_tokens and
# output_tokens whole treats every one of these as the same money.
MIXED_TOKEN_FIELDS = ("input_audio_tokens", "output_audio_tokens",
                      "input_image_tokens", "output_image_tokens")

FINDINGS = ("gap", "unclassified-line-items")


def family(line_item):
    """Map a cost report line_item onto a modality family. Pure.

    Returns "other" for anything unrecognised, and "other" is deliberately loud
    rather than a quiet bucket: the platform ships new billable surfaces, and a
    reconciliation that silently absorbs the next one is worse than none.
    """
    name = str(line_item or "").strip().lower()
    if not name:
        return "other"
    for label, markers in FAMILIES:
        if any(marker in name for marker in markers):
            return label
    return "other"


def reconcile(items, covers):
    """Split spend into what the dashboard covers and what it does not. Pure.

    items is [(line_item, amount, quantity, quantity_unit), ...] as read off
    GET /v1/organization/costs grouped by line_item. covers is the set of family
    names your dashboard actually renders. Amounts that will not parse are
    counted as unreadable rather than as zero, because zero would shrink the gap.
    """
    out = {"total": 0.0, "covered": 0.0, "uncovered": 0.0, "unreadable": 0,
           "by_family": {}, "rows": []}
    wanted = {str(c).strip().lower() for c in covers}
    for line_item, amount, quantity, unit in items:
        try:
            value = float(amount)
        except (TypeError, ValueError):
            out["unreadable"] += 1
            continue
        label = family(line_item)
        out["total"] += value
        out["by_family"][label] = out["by_family"].get(label, 0.0) + value
        if label in wanted:
            out["covered"] += value
        else:
            out["uncovered"] += value
            out["rows"].append((label, str(line_item), value, quantity, unit))
    out["rows"].sort(key=lambda r: -r[2])
    return out


def verdict(recon, tolerance=0.02):
    """Is the remainder rounding or a hole? Pure. Returns (state, detail).

    tolerance is a fraction of total spend, defaulting to 2%, which is about
    where a gap stops being explicable as timing and lag. A gap made mostly of
    line items the script could not classify gets its own state, because the
    repair is to go and read the strings rather than to add a known endpoint.
    """
    total = recon.get("total") or 0.0
    uncovered = recon.get("uncovered") or 0.0
    if total <= 0:
        return ("no-spend",
                "no spend in the window, so there is nothing to reconcile")

    share = uncovered / total
    money = ("$%.2f total, $%.2f (%.1f%%) outside what the dashboard covers"
             % (total, uncovered, share * 100))

    if share < tolerance:
        return ("reconciled",
                "%s, inside the %.1f%% tolerance" % (money, tolerance * 100))

    # Derived from the uncovered rows rather than from by_family, because
    # by_family counts both sides and the question here is only about the half
    # the dashboard cannot render.
    uncovered_by_family = {}
    for label, _item, value, _quantity, _unit in recon.get("rows") or []:
        uncovered_by_family[label] = uncovered_by_family.get(label, 0.0) + value

    other = uncovered_by_family.get("other", 0.0)
    if uncovered > 0 and other / uncovered > 0.5:
        return ("unclassified-line-items",
                "%s, and most of it is on line items this script could not "
                "classify. Read the raw line_item strings before assuming which "
                "endpoint explains them." % money)

    biggest = max(uncovered_by_family.items(), key=lambda kv: kv[1],
                  default=("nothing", 0.0))
    return ("gap",
            "%s. Largest uncovered family is %s at $%.2f."
            % (money, biggest[0], biggest[1]))


def hidden_token_types(result):
    """Non-zero audio and image token counts inside a completions result. Pure.

    Returns a sorted list of (field, value). A dashboard summing input_tokens and
    output_tokens whole is mixing these in with text tokens at the text price.
    """
    out = []
    for field in MIXED_TOKEN_FIELDS:
        try:
            value = int(result.get(field) or 0)
        except (TypeError, ValueError):
            continue
        if value:
            out.append((field, value))
    return sorted(out)


def get(session, path, params=None):
    r = session.get(API + path, params=params or {}, timeout=60)
    if r.status_code == 401:
        raise SystemExit("401 from OpenAI: OPENAI_ADMIN_KEY must be an "
                         "organization admin key, not a project key")
    if r.status_code == 403:
        raise SystemExit("403 from OpenAI: the key is not authorised for "
                         "/v1/organization")
    r.raise_for_status()
    return r.json()


def cost_items(session, start_time):
    """[(line_item, amount, quantity, quantity_unit), ...] over the window."""
    out = []
    page = get(session, "/organization/costs",
               {"start_time": start_time, "limit": 31, "group_by": "line_item"})
    for bucket in page.get("data") or []:
        for result in bucket.get("results") or []:
            out.append((result.get("line_item"),
                        (result.get("amount") or {}).get("value"),
                        result.get("quantity"),
                        result.get("quantity_unit")))
    return out


def surface_volume(session, path, field, start_time, days):
    """Sum one usage surface's own quantity field over the window."""
    total = 0
    page = get(session, path,
               {"start_time": start_time, "bucket_width": "1d", "limit": days})
    for bucket in page.get("data") or []:
        for result in bucket.get("results") or []:
            try:
                total += int(result.get(field) or 0)
            except (TypeError, ValueError):
                pass
    return total


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="days to reconcile (default 30)")
    ap.add_argument("--covers", default="text",
                    help="comma separated families your dashboard renders "
                         "(default 'text', which is what a completions-only "
                         "dashboard covers)")
    ap.add_argument("--tolerance", type=float, default=0.02,
                    help="uncovered share below which the gap is rounding")
    args = ap.parse_args()

    key = os.environ.get("OPENAI_ADMIN_KEY")
    if not key:
        log.error("set OPENAI_ADMIN_KEY (an organization admin key with read scopes)")
        return 2

    session = requests.Session()
    session.headers.update({"Authorization": "Bearer " + key})

    now = dt.datetime.now(dt.timezone.utc)
    start = int((now - dt.timedelta(days=args.days)).timestamp())
    covers = [c for c in args.covers.split(",") if c.strip()]

    recon = reconcile(cost_items(session, start), covers)
    state, detail = verdict(recon, args.tolerance)
    log.info("%-24s %s", state, detail)

    for label, line_item, value, quantity, unit in recon["rows"][:20]:
        log.warning("  uncovered  %-10s $%9.2f   %-28s %s %s",
                    label, value, line_item, quantity or "", unit or "")

    for name, path, field, unit in SURFACES:
        volume = surface_volume(session, path, field, start, args.days)
        if volume:
            log.info("  volume     %-28s %s %s", name, volume, unit)

    if recon["unreadable"]:
        log.warning("  %d cost row(s) had an unreadable amount and were left out "
                    "of both sides", recon["unreadable"])

    if state in FINDINGS:
        log.warning("  repair: drive the spend dashboard from "
                    "/v1/organization/costs grouped by line_item, which is the "
                    "only endpoint denominated in money, and use the "
                    "per-modality usage endpoints to explain why a line moved")
        log.warning("  repair: inside completions, read input_text_tokens, "
                    "input_audio_tokens and input_image_tokens separately "
                    "instead of summing input_tokens whole")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
openai-modality-spend-reconcile.mjs
/**
 * Reconcile an OpenAI token dashboard against the whole bill.
 *
 * Read only. GET requests and nothing else: OPENAI_ADMIN_KEY must be an
 * organization admin key with read scopes.
 *
 * Costs is the only endpoint denominated in money. The per-modality usage
 * endpoints are denominated in characters, seconds, images, sessions and calls.
 */
const API = 'https://api.openai.com/v1';

// Every usage surface, with the field it is denominated in.
const SURFACES = [
  ['completions', '/organization/usage/completions', 'num_model_requests', 'requests'],
  ['embeddings', '/organization/usage/embeddings', 'input_tokens', 'tokens'],
  ['moderations', '/organization/usage/moderations', 'input_tokens', 'tokens'],
  ['audio_speeches', '/organization/usage/audio_speeches', 'characters', 'characters'],
  ['audio_transcriptions', '/organization/usage/audio_transcriptions', 'seconds', 'seconds'],
  ['images', '/organization/usage/images', 'images', 'images'],
  ['code_interpreter_sessions', '/organization/usage/code_interpreter_sessions',
    'num_sessions', 'sessions'],
  ['file_search_calls', '/organization/usage/file_search_calls', 'num_requests', 'calls'],
  ['web_search_calls', '/organization/usage/web_search_calls', 'num_requests', 'calls'],
];

// Matched in order. Audio, image and tool come before text because
// "gpt-image-1" and "gpt-4o-audio-preview" both contain a text-model substring.
const FAMILIES = [
  ['audio', ['audio', 'speech', 'transcription', 'whisper', 'tts', 'realtime']],
  ['image', ['image', 'dall-e']],
  ['tool', ['web search', 'web_search', 'file search', 'file_search',
            'code interpreter', 'code_interpreter', 'container']],
  ['embedding', ['embedding']],
  ['moderation', ['moderation']],
  ['text', ['input tokens', 'output tokens', 'cached input', 'cached_input',
            'gpt-', 'o1-', 'o3', 'o4-', 'chat']],
];

const MIXED_TOKEN_FIELDS = ['input_audio_tokens', 'output_audio_tokens',
                            'input_image_tokens', 'output_image_tokens'];

const FINDINGS = ['gap', 'unclassified-line-items'];

/**
 * Map a cost report line_item onto a modality family. Pure. "other" is
 * deliberately loud rather than a quiet bucket.
 */
export function family(lineItem) {
  const name = String(lineItem ?? '').trim().toLowerCase();
  if (!name) return 'other';
  for (const [label, markers] of FAMILIES) {
    if (markers.some((m) => name.includes(m))) return label;
  }
  return 'other';
}

/**
 * Split spend into what the dashboard covers and what it does not. Pure.
 * Amounts that will not parse count as unreadable rather than as zero, because
 * zero would shrink the gap.
 */
export function reconcile(items, covers) {
  const out = { total: 0, covered: 0, uncovered: 0, unreadable: 0,
                by_family: {}, rows: [] };
  const wanted = new Set([...covers].map((c) => String(c).trim().toLowerCase()));
  for (const [lineItem, amount, quantity, unit] of items) {
    const value = Number(amount);
    if (!Number.isFinite(value) || amount === null || amount === undefined
        || amount === '') {
      out.unreadable += 1;
      continue;
    }
    const label = family(lineItem);
    out.total += value;
    out.by_family[label] = (out.by_family[label] ?? 0) + value;
    if (wanted.has(label)) out.covered += value;
    else {
      out.uncovered += value;
      out.rows.push([label, String(lineItem), value, quantity, unit]);
    }
  }
  out.rows.sort((a, b) => b[2] - a[2]);
  return out;
}

/**
 * Is the remainder rounding or a hole? Pure. Returns [state, detail].
 * A gap made mostly of unclassifiable line items gets its own state, because
 * the repair is to read the strings rather than to add a known endpoint.
 */
export function verdict(recon, tolerance = 0.02) {
  const total = recon.total ?? 0;
  const uncovered = recon.uncovered ?? 0;
  if (total <= 0) {
    return ['no-spend', 'no spend in the window, so there is nothing to reconcile'];
  }

  const share = uncovered / total;
  const money = `$${total.toFixed(2)} total, $${uncovered.toFixed(2)} ` +
                `(${(share * 100).toFixed(1)}%) outside what the dashboard covers`;

  if (share < tolerance) {
    return ['reconciled', `${money}, inside the ${(tolerance * 100).toFixed(1)}% tolerance`];
  }

  // Derived from the uncovered rows rather than from by_family, because
  // by_family counts both sides and the question here is only about the half
  // the dashboard cannot render.
  const uncoveredByFamily = {};
  for (const [label, , value] of recon.rows ?? []) {
    uncoveredByFamily[label] = (uncoveredByFamily[label] ?? 0) + value;
  }

  const other = uncoveredByFamily.other ?? 0;
  if (uncovered > 0 && other / uncovered > 0.5) {
    return ['unclassified-line-items',
      `${money}, and most of it is on line items this script could not ` +
      'classify. Read the raw line_item strings before assuming which endpoint ' +
      'explains them.'];
  }

  let biggest = ['nothing', 0];
  for (const [label, value] of Object.entries(uncoveredByFamily)) {
    if (value > biggest[1]) biggest = [label, value];
  }
  return ['gap',
    `${money}. Largest uncovered family is ${biggest[0]} at $${biggest[1].toFixed(2)}.`];
}

/**
 * Non-zero audio and image token counts inside a completions result. Pure.
 * A dashboard summing input_tokens and output_tokens whole is mixing these in
 * with text tokens at the text price.
 */
export function hiddenTokenTypes(result) {
  const out = [];
  for (const field of MIXED_TOKEN_FIELDS) {
    const value = Number(result[field] ?? 0);
    if (Number.isFinite(value) && value !== 0) out.push([field, Math.trunc(value)]);
  }
  return out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
}

async function get(key, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status === 401) {
    throw new Error('401 from OpenAI: OPENAI_ADMIN_KEY must be an organization ' +
                    'admin key, not a project key');
  }
  if (res.status === 403) {
    throw new Error('403 from OpenAI: the key is not authorised for /v1/organization');
  }
  if (!res.ok) throw new Error(`${res.status} from ${path}`);
  return res.json();
}

async function costItems(key, startTime) {
  const out = [];
  const page = await get(key, '/organization/costs',
    { start_time: startTime, limit: 31, group_by: 'line_item' });
  for (const bucket of page.data ?? []) {
    for (const result of bucket.results ?? []) {
      out.push([result.line_item, result.amount?.value, result.quantity,
                result.quantity_unit]);
    }
  }
  return out;
}

async function surfaceVolume(key, path, field, startTime, days) {
  let total = 0;
  const page = await get(key, path,
    { start_time: startTime, bucket_width: '1d', limit: days });
  for (const bucket of page.data ?? []) {
    for (const result of bucket.results ?? []) {
      const n = Number(result[field] ?? 0);
      if (Number.isFinite(n)) total += Math.trunc(n);
    }
  }
  return total;
}

async function main() {
  const key = process.env.OPENAI_ADMIN_KEY;
  if (!key) {
    console.error('set OPENAI_ADMIN_KEY (an organization admin key with read scopes)');
    process.exitCode = 2;
    return;
  }

  const days = Number(process.env.DAYS ?? 30);
  const covers = (process.env.COVERS ?? 'text').split(',').filter((c) => c.trim());
  const tolerance = Number(process.env.TOLERANCE ?? 0.02);

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

  const recon = reconcile(await costItems(key, start), covers);
  const [state, detail] = verdict(recon, tolerance);
  console.log(`${state.padEnd(24)} ${detail}`);

  for (const [label, lineItem, value, quantity, unit] of recon.rows.slice(0, 20)) {
    console.warn(`  uncovered  ${label.padEnd(10)} $${value.toFixed(2).padStart(9)}` +
                 `   ${String(lineItem).padEnd(28)} ${quantity ?? ''} ${unit ?? ''}`);
  }

  for (const [name, path, field, unit] of SURFACES) {
    const volume = await surfaceVolume(key, path, field, start, days);
    if (volume) console.log(`  volume     ${name.padEnd(28)} ${volume} ${unit}`);
  }

  if (recon.unreadable) {
    console.warn(`  ${recon.unreadable} cost row(s) had an unreadable amount and ` +
                 'were left out of both sides');
  }

  if (FINDINGS.includes(state)) {
    console.warn('  repair: drive the spend dashboard from /v1/organization/costs ' +
      'grouped by line_item, which is the only endpoint denominated in money, ' +
      'and use the per-modality usage endpoints to explain why a line moved');
    console.warn('  repair: inside completions, read input_text_tokens, ' +
      'input_audio_tokens and input_image_tokens separately instead of summing ' +
      'input_tokens whole');
    process.exitCode = 1;
    return;
  }
  process.exitCode = 0;
}

// Only run when invoked directly, so importing this from the test file does not
// run main(), fail on the missing key, and set an exit code that fails the suite.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Two of these tests exist because of specific string collisions that would make the reconciliation lie. gpt-image-1 contains gpt- and is not billed in text tokens; gpt-4o-audio-preview contains the same substring and is not either. Classifying by first match in the wrong order would quietly move real money into the covered column. The rest hold the states apart: a small gap is rounding, a large one is a hole, a gap made of line items nobody can name is a third thing, and a cost row with an unparseable amount is excluded from both sides rather than counted as zero.

test_openai_modality_spend_reconcile.py
from openai_modality_spend_reconcile import (family, hidden_token_types,
                                             reconcile, verdict)


def items(*rows):
    """[(line_item, amount, quantity, quantity_unit), ...] from the cost report."""
    return [(r[0], r[1], r[2] if len(r) > 2 else None,
             r[3] if len(r) > 3 else None) for r in rows]


def test_the_dashboard_covers_text_and_the_bill_does_not_stop_there():
    recon = reconcile(items(("gpt-5, input tokens", 9000.00),
                            ("gpt-5, output tokens", 6487.43),
                            ("Text-to-speech", 1802.40, 14209881, "characters"),
                            ("Web search", 784.00, 78400, "requests"),
                            ("Image generation", 328.28, 6120, "images")),
                      covers=["text"])
    state, detail = verdict(recon)
    assert state == "gap"
    assert "$18402.11 total" in detail
    assert "$2914.68" in detail
    assert recon["rows"][0][0] == "audio"


def test_model_names_that_look_like_text_but_are_not():
    # Both contain "gpt-", and matching text first would move real money into
    # the covered column and shrink the gap to nothing.
    assert family("gpt-image-1") == "image"
    assert family("gpt-4o-audio-preview, input tokens") == "audio"
    assert family("gpt-5, input tokens") == "text"
    assert family("Code interpreter session") == "tool"
    assert family("text-embedding-3-small") == "embedding"


def test_a_small_gap_is_rounding_and_a_large_one_is_not():
    small = reconcile(items(("gpt-5, input tokens", 1000.00),
                            ("Moderations", 5.00)), covers=["text"])
    assert verdict(small)[0] == "reconciled"
    assert verdict(small, tolerance=0.001)[0] == "gap"


def test_line_items_nobody_can_classify_are_their_own_state():
    recon = reconcile(items(("gpt-5, input tokens", 500.00),
                            ("Some New Surface We Shipped Tuesday", 400.00)),
                      covers=["text"])
    state, detail = verdict(recon)
    assert state == "unclassified-line-items"
    assert "read the raw line_item strings" in detail.lower()


def test_an_unreadable_amount_is_not_counted_as_zero():
    recon = reconcile(items(("gpt-5, input tokens", 100.00),
                            ("Text-to-speech", None),
                            ("Web search", "n/a")), covers=["text"])
    assert recon["unreadable"] == 2
    assert recon["total"] == 100.00
    assert verdict(recon)[0] == "reconciled"


def test_nothing_to_reconcile_is_not_a_finding():
    assert verdict(reconcile([], covers=["text"]))[0] == "no-spend"


def test_multimodal_tokens_hide_inside_the_completions_result():
    result = {"input_tokens": 100000, "output_tokens": 8000,
              "input_text_tokens": 60000, "input_audio_tokens": 40000,
              "output_audio_tokens": 3000, "input_image_tokens": 0}
    assert hidden_token_types(result) == [("input_audio_tokens", 40000),
                                          ("output_audio_tokens", 3000)]
    assert hidden_token_types({"input_tokens": 100000}) == []
openai-modality-spend-reconcile.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { family, hiddenTokenTypes, reconcile, verdict }
  from './openai-modality-spend-reconcile.mjs';

/** [[line_item, amount, quantity, quantity_unit], ...] from the cost report. */
function items(rows) {
  return rows.map((r) => [r[0], r[1], r[2] ?? null, r[3] ?? null]);
}

test('the dashboard covers text and the bill does not stop there', () => {
  const recon = reconcile(items([
    ['gpt-5, input tokens', 9000.00],
    ['gpt-5, output tokens', 6487.43],
    ['Text-to-speech', 1802.40, 14209881, 'characters'],
    ['Web search', 784.00, 78400, 'requests'],
    ['Image generation', 328.28, 6120, 'images'],
  ]), ['text']);
  const [state, detail] = verdict(recon);
  assert.equal(state, 'gap');
  assert.match(detail, /18402.11 total/);
  assert.match(detail, /2914.68/);
  assert.equal(recon.rows[0][0], 'audio');
});

test('model names that look like text but are not', () => {
  assert.equal(family('gpt-image-1'), 'image');
  assert.equal(family('gpt-4o-audio-preview, input tokens'), 'audio');
  assert.equal(family('gpt-5, input tokens'), 'text');
  assert.equal(family('Code interpreter session'), 'tool');
  assert.equal(family('text-embedding-3-small'), 'embedding');
});

test('a small gap is rounding and a large one is not', () => {
  const small = reconcile(items([['gpt-5, input tokens', 1000.00],
                                 ['Moderations', 5.00]]), ['text']);
  assert.equal(verdict(small)[0], 'reconciled');
  assert.equal(verdict(small, 0.001)[0], 'gap');
});

test('line items nobody can classify are their own state', () => {
  const recon = reconcile(items([['gpt-5, input tokens', 500.00],
                                 ['Some New Surface We Shipped Tuesday', 400.00]]),
                          ['text']);
  const [state, detail] = verdict(recon);
  assert.equal(state, 'unclassified-line-items');
  assert.match(detail.toLowerCase(), /read the raw line_item strings/);
});

test('an unreadable amount is not counted as zero', () => {
  const recon = reconcile(items([['gpt-5, input tokens', 100.00],
                                 ['Text-to-speech', null],
                                 ['Web search', 'n/a']]), ['text']);
  assert.equal(recon.unreadable, 2);
  assert.equal(recon.total, 100.00);
  assert.equal(verdict(recon)[0], 'reconciled');
});

test('nothing to reconcile is not a finding', () => {
  assert.equal(verdict(reconcile([], ['text']))[0], 'no-spend');
});

test('multimodal tokens hide inside the completions result', () => {
  const result = {
    input_tokens: 100000, output_tokens: 8000, input_text_tokens: 60000,
    input_audio_tokens: 40000, output_audio_tokens: 3000, input_image_tokens: 0,
  };
  assert.deepEqual(hiddenTokenTypes(result),
                   [['input_audio_tokens', 40000], ['output_audio_tokens', 3000]]);
  assert.deepEqual(hiddenTokenTypes({ input_tokens: 100000 }), []);
});

FAQ

Which endpoint should a spend dashboard actually be built on?

/v1/organization/costs grouped by line_item. It is the only one denominated in money, it covers every billable surface including ones that do not exist yet, and it is what the invoice agrees with. The per-modality usage endpoints belong underneath it, answering why a line item moved rather than what the total is.

Why can the usage endpoints not just report dollars?

Because they report quantities, and the quantities are in incompatible units: characters for speech, seconds for transcription, images for generation, sessions for code interpreter, calls for web and file search, tokens for chat. Pricing each of those requires a price table that changes without warning, which is precisely the thing the cost report already does for you server-side.

What is the smallest useful version of this check?

One call. Fetch costs grouped by line_item for the last thirty days and read the list of distinct line_item strings out loud. If any of them names something your dashboard does not render, you have the finding, and the full sweep of usage endpoints is only there to tell you how much of the thing was bought.

Does web search really bill separately from tokens?

Yes, per call rather than per token, which is what makes it invisible in a token graph. An agent that searches twice per turn generates a line item that scales with conversation volume and never appears in input or output token counts. Code interpreter is the same shape, billed by container session with a free allowance you can exceed without noticing.

What about audio and images inside a chat completion?

Those do flow through the completions endpoint, and they arrive as separate token type fields: input_audio_tokens, output_audio_tokens, input_image_tokens and their text sibling input_text_tokens. They are priced differently from text tokens, so a dashboard adding input_tokens and output_tokens whole is mispricing them rather than missing them. Read the type fields individually.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.