Token Pricing

Token-Based Pricing by Quality and Size

Sunburst, Flare and gpt-image-2 86 share one price list per million tokens: text input $5, image input $8 and image output $30 (cached inputs $1.25 and $2 in the Responses API; the Batch API halves all rates). The older gpt-image-1 charges $40 per million output tokens. OpenAI 86 's cost calculator derives output tokens from a patch grid per quality level, and its formula ports to a few lines:

budget.mjs, part 1: rates and output tokens for gpt-image-2.5JavaScript
export const LEDGER = process.env.IMAGE_LEDGER ?? 'image-api-ledger.md';
export const CAP = Number(process.env.IMAGE_CAP ?? 5);             // US dollars
const RATE = { text: 5, image: 8, out: 30 };                       // $ per 1M tokens
const PATCHES = { low: 16, medium: 24, high: 48, xhigh: 64, max: 96 };   // gpt-image-2.5
export function outputTokens(size, quality, partials = 0) {   // OpenAI's calculator formula
  const [w, h] = size.split('x').map(Number);
  const p = PATCHES[quality], s = p / (Math.max(w, h) / Math.min(w, h));
  const f = Math.floor(s), q = s - f === 0.5 ? f + (f % 2) : Math.round(s);
  return Math.ceil(p * q * (2e6 + w * h) / 4e6) + 100 * partials;
}
export const dollars = ({ textIn = 0, imageIn = 0, out = 0 }) =>

It matched every non-streamed call in this chapter exactly (120, 196, 215 and 229 tokens):

Output tokens and cost per gpt-image-2.5 image, from the calculator's formula
Size low medium high max
1024x1024 196, $0.006 439, $0.013 1756, $0.053 7024, $0.211
1536x1024 158, $0.005 343, $0.010 1372, $0.041 5488, $0.165
3840x2160 371, $0.011 865, $0.026 3336, $0.100 13342, $0.400

Quality multiplies cost 36 times from low to max, while a 4K image at low costs about a cent. Non-square sizes get fewer patches on the short side, so 1536x1024 undercuts the square.

A heatmap of gpt-image-2.5 output tokens and dollars by size and quality, from OpenAI's calculator formulaHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 11px system-ui, sans-serif; color: #263238; }
  svg { width: 100%; max-width: 600px; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p>Show <label><input type="radio" name="m" value="usd" checked> dollars</label> <label><input type="radio" name="m" value="tok"> tokens</label>
  · output rate <select id="rate"><option value="30">$30/M (gpt-image-2, 2.5)</option><option value="40">$40/M (gpt-image-1)</option><option value="15">$15/M (Batch API)</option></select></p>
<svg viewBox="0 0 600 250" font-size="11"></svg>
<script>
  const PATCHES = { low: 16, medium: 24, high: 48, xhigh: 64, max: 96 };
  function outputTokens(size, quality) {                     // OpenAI's calculator formula
    const [w, h] = size.split('x').map(Number);
    const p = PATCHES[quality], s = p / (Math.max(w, h) / Math.min(w, h));
    const f = Math.floor(s), q = s - f === 0.5 ? f + (f % 2) : Math.round(s);
    return Math.ceil(p * q * (2e6 + w * h) / 4e6);
  }
  const sizes = ['512x1536', '1024x1024', '1536x1024', '1024x1536', '2048x2048', '3840x2160'];
  const qualities = Object.keys(PATCHES);
  const x = d3.scaleBand(qualities, [90, 590]).padding(0.05), y = d3.scaleBand(sizes, [20, 240]).padding(0.05);
  const svg = d3.select('svg');
  svg.selectAll('text.q').data(qualities).join('text').attr('class', 'q').attr('x', q => x(q) + x.bandwidth() / 2)
    .attr('y', 12).attr('text-anchor', 'middle').attr('font-weight', 'bold').text(q => q);
  svg.selectAll('text.s').data(sizes).join('text').attr('class', 's').attr('x', 84).attr('text-anchor', 'end')
    .attr('y', s => y(s) + y.bandwidth() / 2).attr('dy', '0.35em').text(s => s);
  const cells = sizes.flatMap(s => qualities.map(q => ({ s, q, tokens: outputTokens(s, q) })));
  const color = d3.scaleSequentialLog([50, 20000], d3.interpolateYlOrRd);
  const cell = svg.selectAll('g.c').data(cells).join('g').attr('class', 'c').attr('transform', d => `translate(${x(d.q)},${y(d.s)})`);
  cell.append('rect').attr('width', x.bandwidth()).attr('height', y.bandwidth()).attr('fill', d => color(d.tokens));
  const label = cell.append('text').attr('x', x.bandwidth() / 2).attr('y', y.bandwidth() / 2).attr('dy', '0.35em')
    .attr('text-anchor', 'middle').attr('fill', d => d.tokens > 3000 ? '#fff' : '#263238');
  function update() {
    const m = document.querySelector('input[name=m]:checked').value, rate = +document.getElementById('rate').value;
    label.text(d => m === 'tok' ? d.tokens.toLocaleString() : `$${(d.tokens * rate / 1e6).toFixed(3)}`);
  }
  document.querySelectorAll('input, select').forEach(e => e.addEventListener('input', update));
  update();
</script>