Repository navigation
fix(billing): split image input tokens for gpt-image-2 #2176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
59c4efb
913926b
2d29c96
44729de
c40ab32
75d6d88
feb9cbd
b608d99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { models } from "@llmgateway/models"; | ||
|
|
||
| import { calculateCosts } from "./costs.js"; | ||
|
|
||
| const { mockGetEffectiveDiscount } = vi.hoisted(() => ({ | ||
|
|
@@ -126,6 +128,8 @@ describe("calculateCosts", () => { | |
| null, | ||
| null, | ||
| undefined, | ||
| null, | ||
| null, | ||
| { | ||
| cacheWriteTokens: 1659, | ||
| }, | ||
|
|
@@ -160,6 +164,8 @@ describe("calculateCosts", () => { | |
| null, | ||
| null, | ||
| undefined, | ||
| null, | ||
| null, | ||
| { | ||
| cacheWriteTokens: 1000, | ||
| cacheWrite1hTokens: 700, | ||
|
|
@@ -192,6 +198,8 @@ describe("calculateCosts", () => { | |
| null, | ||
| null, | ||
| undefined, | ||
| null, | ||
| null, | ||
| { | ||
| cacheWriteTokens: 1000, | ||
| }, | ||
|
|
@@ -450,7 +458,7 @@ describe("calculateCosts", () => { | |
| "low", | ||
| ); | ||
|
|
||
| const expectedInputCost = 1000 * (8 / 1e6); | ||
| const expectedInputCost = 1000 * (5 / 1e6); | ||
| const expectedImageOutputCost = 2000 * (30 / 1e6); | ||
|
|
||
| expect(result.imageOutputTokens).toBe(2000); | ||
|
|
@@ -462,6 +470,100 @@ describe("calculateCosts", () => { | |
| ); | ||
| }); | ||
|
|
||
| it("should bill reported image input tokens at imageInputPrice for gpt-image-2 edits", async () => { | ||
| // /v1/images/edits sends input images as part of the prompt. OpenAI's | ||
| // usage payload reports text vs image tokens via input_tokens_details. | ||
| // We expect the gateway to bill the image portion at imageInputPrice | ||
| // ($8/M) and the remaining text portion at inputPrice ($5/M) — without | ||
| // double-billing image tokens at the text rate. | ||
| const promptTokens = 524; // 12 text + 512 image (from real OpenAI response) | ||
| const reportedImageInputTokens = 512; | ||
| const completionTokens = 196; | ||
| const reportedImageOutputTokens = 196; | ||
|
|
||
| const result = await calculateCosts( | ||
| "gpt-image-2", | ||
| "openai", | ||
| promptTokens, | ||
| completionTokens, | ||
| null, // cachedTokens | ||
| undefined, // fullOutput | ||
| null, // reasoningTokens | ||
| 1, // outputImageCount | ||
| "1024x1024", // imageSize | ||
| 0, // inputImageCount (not used for openai) | ||
| null, // webSearchCount | ||
| null, // organizationId | ||
| "low", // imageQuality | ||
| reportedImageInputTokens, | ||
| reportedImageOutputTokens, | ||
| ); | ||
|
|
||
| const expectedTextInputCost = | ||
| (promptTokens - reportedImageInputTokens) * (5 / 1e6); | ||
| const expectedImageInputCost = reportedImageInputTokens * (8 / 1e6); | ||
| const expectedImageOutputCost = reportedImageOutputTokens * (30 / 1e6); | ||
|
|
||
| expect(result.imageInputTokens).toBe(reportedImageInputTokens); | ||
| expect(result.imageInputCost).toBeCloseTo(expectedImageInputCost); | ||
| expect(result.imageOutputTokens).toBe(reportedImageOutputTokens); | ||
| expect(result.imageOutputCost).toBeCloseTo(expectedImageOutputCost); | ||
| expect(result.inputCost).toBeCloseTo( | ||
| expectedTextInputCost + expectedImageInputCost, | ||
| ); | ||
| expect(result.outputCost).toBeCloseTo(expectedImageOutputCost); | ||
| expect(result.totalCost).toBeCloseTo( | ||
| expectedTextInputCost + expectedImageInputCost + expectedImageOutputCost, | ||
| ); | ||
| }); | ||
|
|
||
| it("should apply azure discount on top of split image/text input pricing for gpt-image-2", async () => { | ||
| const promptTokens = 524; | ||
| const reportedImageInputTokens = 512; | ||
| const completionTokens = 196; | ||
| const reportedImageOutputTokens = 196; | ||
|
|
||
| const result = await calculateCosts( | ||
| "gpt-image-2", | ||
| "azure", | ||
| promptTokens, | ||
| completionTokens, | ||
| null, | ||
| undefined, | ||
| null, | ||
| 1, | ||
| "1024x1024", | ||
| 0, | ||
| null, | ||
| null, | ||
| "low", | ||
| reportedImageInputTokens, | ||
| reportedImageOutputTokens, | ||
| ); | ||
|
|
||
| // Read discount from the model definition so the test stays correct | ||
| // even if the azure discount value changes. | ||
| const azureProvider = models | ||
| .find((m) => m.id === "gpt-image-2") | ||
| ?.providers.find((p) => p.providerId === "azure"); | ||
| const discountMultiplier = 1 - (azureProvider?.discount ?? 0); | ||
| const expectedTextInputCost = | ||
| (promptTokens - reportedImageInputTokens) * | ||
| (5 / 1e6) * | ||
| discountMultiplier; | ||
| const expectedImageInputCost = | ||
| reportedImageInputTokens * (8 / 1e6) * discountMultiplier; | ||
| const expectedImageOutputCost = | ||
| reportedImageOutputTokens * (30 / 1e6) * discountMultiplier; | ||
|
|
||
| expect(result.imageInputTokens).toBe(reportedImageInputTokens); | ||
| expect(result.imageInputCost).toBeCloseTo(expectedImageInputCost); | ||
| expect(result.inputCost).toBeCloseTo( | ||
| expectedTextInputCost + expectedImageInputCost, | ||
| ); | ||
| expect(result.outputCost).toBeCloseTo(expectedImageOutputCost); | ||
| }); | ||
|
Comment on lines
+558
to
+603
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Azure test is missing The companion OpenAI test (lines 507–517) checks all six cost breakdown fields plus 🛡️ Proposed additions expect(result.imageInputTokens).toBe(reportedImageInputTokens);
expect(result.imageInputCost).toBeCloseTo(expectedImageInputCost);
expect(result.inputCost).toBeCloseTo(
expectedTextInputCost + expectedImageInputCost,
);
expect(result.outputCost).toBeCloseTo(expectedImageOutputCost);
+ expect(result.imageOutputTokens).toBe(reportedImageOutputTokens);
+ expect(result.imageOutputCost).toBeCloseTo(expectedImageOutputCost);
+ expect(result.totalCost).toBeCloseTo(
+ expectedTextInputCost + expectedImageInputCost + expectedImageOutputCost,
+ );
});🤖 Prompt for AI Agents |
||
|
|
||
| it("should return null for all image fields when no images", async () => { | ||
| const result = await calculateCosts("gpt-4", "openai", 100, 50, null); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -115,6 +115,8 @@ export async function calculateCosts( | |
| webSearchCount: number | null = null, | ||
| organizationId: string | null = null, | ||
|
Comment on lines
115
to
116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because the new split pricing depends on Useful? React with 👍 / 👎. |
||
| imageQuality?: string, | ||
| reportedImageInputTokens: number | null = null, | ||
| reportedImageOutputTokens: number | null = null, | ||
| options?: { | ||
| cacheWriteTokens?: number | null; | ||
| cacheWrite1hTokens?: number | null; | ||
|
|
@@ -330,18 +332,30 @@ export async function calculateCosts( | |
| return byResolution[size ?? "default"] ?? byResolution["default"]; | ||
| } | ||
|
|
||
| // Track image input tokens separately (for Google image generation models). | ||
| // Uses imageInputTokensByResolution for per-resolution token counts and | ||
| // imageInputPrice for the per-token price. Falls back to 560 tokens/image | ||
| // with imageInputPrice if no resolution map is present. | ||
| // Track image input tokens separately. For image-output models (e.g. | ||
| // gpt-image-2) we prefer the upstream-reported image_tokens count, since | ||
| // the provider tokenises the input image and bills against it directly. | ||
| // For Google image-generation models we fall back to inputImageCount * | ||
| // imageInputTokensByResolution[size] (or 560/image legacy default). | ||
| const imageInputTokensPerImage = resolveTokensPerImage( | ||
| providerInfo.imageInputTokensByResolution, | ||
| imageSize, | ||
| ); | ||
| const imageInputPricePerToken = providerInfo.imageInputPrice; | ||
| const isImageOutputModel = modelInfo.output?.includes("image") ?? false; | ||
| let imageInputTokens: number | null = null; | ||
| let imageInputCost: Decimal | null = null; | ||
| if (imageInputPricePerToken && inputImageCount > 0) { | ||
| if ( | ||
| imageInputPricePerToken && | ||
| isImageOutputModel && | ||
| reportedImageInputTokens && | ||
| reportedImageInputTokens > 0 | ||
|
Comment on lines
+351
to
+352
|
||
| ) { | ||
| imageInputTokens = reportedImageInputTokens; | ||
| imageInputCost = new Decimal(imageInputTokens) | ||
| .times(imageInputPricePerToken) | ||
| .times(discountMultiplier); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When OpenAI/Azure returns Useful? React with 👍 / 👎. |
||
| } else if (imageInputPricePerToken && inputImageCount > 0) { | ||
| const LEGACY_TOKENS_PER_INPUT_IMAGE = 560; | ||
| const tokensPerImage = | ||
| imageInputTokensPerImage ?? LEGACY_TOKENS_PER_INPUT_IMAGE; | ||
|
|
@@ -365,8 +379,18 @@ export async function calculateCosts( | |
| cachedReadTokens - | ||
| separatelyPricedCacheWriteTokens, | ||
| ); | ||
| // For providers whose upstream usage already folds image tokens into | ||
| // prompt_tokens (OpenAI/Azure/xAI on image-output models), subtract them | ||
| // so the image portion isn't billed at both inputPrice and imageInputPrice. | ||
| const promptIncludesImageTokens = | ||
| isImageOutputModel && | ||
| (provider === "openai" || provider === "azure" || provider === "xai"); | ||
| const billableTextPromptTokens = | ||
| promptIncludesImageTokens && imageInputTokens | ||
| ? Math.max(0, uncachedPromptTokens - imageInputTokens) | ||
| : uncachedPromptTokens; | ||
| // inputCost includes both text and image input costs when applicable | ||
| const inputCost = new Decimal(uncachedPromptTokens) | ||
| const inputCost = new Decimal(billableTextPromptTokens) | ||
| .times(inputPrice) | ||
| .times(discountMultiplier) | ||
| .plus(imageInputCost ?? 0); | ||
|
|
@@ -396,11 +420,15 @@ export async function calculateCosts( | |
| if (imageOutputPricePerToken && outputImageCount > 0) { | ||
| const LEGACY_DEFAULT_TOKENS_PER_IMAGE = 1120; | ||
| imageOutputTokens = | ||
| imageOutputTokensPerImage !== undefined | ||
| ? outputImageCount * imageOutputTokensPerImage | ||
| : totalOutputTokens > 0 | ||
| ? totalOutputTokens | ||
| : outputImageCount * LEGACY_DEFAULT_TOKENS_PER_IMAGE; | ||
| isImageOutputModel && | ||
| reportedImageOutputTokens && | ||
| reportedImageOutputTokens > 0 | ||
| ? reportedImageOutputTokens | ||
| : imageOutputTokensPerImage !== undefined | ||
| ? outputImageCount * imageOutputTokensPerImage | ||
| : totalOutputTokens > 0 | ||
| ? totalOutputTokens | ||
| : outputImageCount * LEGACY_DEFAULT_TOKENS_PER_IMAGE; | ||
|
Comment on lines
445
to
+454
|
||
| const textTokens = Math.max(0, totalOutputTokens - imageOutputTokens); | ||
|
|
||
| imageOutputCost = new Decimal(imageOutputTokens) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against false positives by asserting Azure model mapping exists.
If the lookup fails,
discountMultiplierbecomes1and this test no longer proves Azure-discount behavior.💡 Proposed test hardening
const azureProvider = models .find((m) => m.id === "gpt-image-2") ?.providers.find((p) => p.providerId === "azure"); + expect(azureProvider).toBeDefined(); const discountMultiplier = 1 - (azureProvider?.discount ?? 0);📝 Committable suggestion
🤖 Prompt for AI Agents