From 8e7319e33025788f0dc505d3c684c847188b449b Mon Sep 17 00:00:00 2001 From: Rayan-and-beyond <263488867+Rayan-and-beyond@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:52:46 +0000 Subject: [PATCH] fix: estimate image_url tokens independently of URL length --- src/index.mjs | 7 ++++++- test/basic.test.mjs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/index.mjs b/src/index.mjs index ecb07e4..89c47ce 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -13,6 +13,10 @@ export function estimateTokens(text) { return Math.max(Math.ceil(chars / 4), Math.ceil(words * 1.3)); } +// Built-in flat estimate for image inputs. Image token accounting varies by model, +// so keep this deterministic and independent of URL length. +const IMAGE_TOKENS = 85; + // Flatten any supported payload into text units: {role, kind, text}. // Supported: an array of messages, or { system, messages }. // Message content may be a string or an array of Anthropic-style blocks. @@ -26,6 +30,7 @@ function blocksOf(content, role) { else if (b && b.type === "text") out.push({ role, kind: "text", text: b.text || "" }); else if (b && b.type === "tool_use") out.push({ role, kind: "tool_use", text: JSON.stringify(b.input || {}) }); else if (b && b.type === "tool_result") out.push({ role, kind: "tool_result", text: typeof b.content === "string" ? b.content : JSON.stringify(b.content ?? "") }); + else if (b && b.type === "image_url") out.push({ role, kind: "image_url", text: "", fixedTokens: IMAGE_TOKENS }); else out.push({ role, kind: (b && b.type) || "other", text: JSON.stringify(b) }); } return out; @@ -56,7 +61,7 @@ function units(payload) { // Report the token breakdown of a payload and where the tokens are going. export function analyzePayload(payload, { pricePerMTok = 3, counter = estimateTokens, top = 10 } = {}) { - const us = units(payload).map((u) => ({ ...u, tokens: counter(u.text) })); + const us = units(payload).map((u) => ({ ...u, tokens: u.fixedTokens ?? counter(u.text) })); const total = us.reduce((a, u) => a + u.tokens, 0); const byKind = {}, byRole = {}; for (const u of us) { diff --git a/test/basic.test.mjs b/test/basic.test.mjs index 5c4158f..ac818d8 100644 --- a/test/basic.test.mjs +++ b/test/basic.test.mjs @@ -172,3 +172,16 @@ test("units rejects non-array payload.messages with TypeError", () => { { name: "TypeError", message: /payload\.messages must be an array/ }, ); }); + +test("analyzePayload charges image_url blocks a flat documented estimate", () => { + const short = analyzePayload({ messages: [{ role: "user", content: [ + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ] }] }); + const long = analyzePayload({ messages: [{ role: "user", content: [ + { type: "image_url", image_url: { url: `https://example.com/${"a".repeat(2000)}.png` } }, + ] }] }); + + assert.equal(short.byKind.image_url, 85); + assert.equal(long.byKind.image_url, 85); + assert.equal(short.totalTokens, long.totalTokens); +});