-
Notifications
You must be signed in to change notification settings - Fork 188
feat(token-usage): enhance token estimation for images #1202
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 all commits
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 |
|---|---|---|
|
|
@@ -2286,11 +2286,12 @@ chat.openapi(completions, async (c) => { | |
| let reasoningTokens = null; | ||
| let cachedTokens = null; | ||
| let streamingToolCalls = null; | ||
| let imageByteSize = 0; // Track total image data size for token estimation | ||
| let doneSent = false; // Track if [DONE] has been sent | ||
| let buffer = ""; // Buffer for accumulating partial data across chunks (string for SSE) | ||
| let binaryBuffer = new Uint8Array(0); // Buffer for binary event streams (AWS Bedrock) | ||
| let rawUpstreamData = ""; // Raw data received from upstream provider | ||
| const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB limit | ||
| // const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB limit | ||
| const isAwsBedrock = usedProvider === "aws-bedrock"; | ||
|
|
||
| try { | ||
|
|
@@ -2331,14 +2332,14 @@ chat.openapi(completions, async (c) => { | |
| rawUpstreamData += chunk; | ||
| } | ||
|
|
||
| // Check buffer size to prevent memory exhaustion | ||
| if (buffer.length > MAX_BUFFER_SIZE) { | ||
| logger.warn( | ||
| "Buffer size exceeded 10MB, clearing buffer to prevent memory exhaustion", | ||
| ); | ||
| buffer = ""; | ||
| continue; | ||
| } | ||
| // // Check buffer size to prevent memory exhaustion | ||
| // if (buffer.length > MAX_BUFFER_SIZE) { | ||
| // logger.warn( | ||
| // "Buffer size exceeded 10MB, clearing buffer to prevent memory exhaustion", | ||
| // ); | ||
| // buffer = ""; | ||
| // continue; | ||
| // } | ||
|
|
||
| // Process SSE events from buffer | ||
| let processedLength = 0; | ||
|
|
@@ -2504,7 +2505,15 @@ chat.openapi(completions, async (c) => { | |
| } | ||
|
|
||
| if (finalCompletionTokens === null) { | ||
| finalCompletionTokens = estimateTokensFromContent(fullContent); | ||
| let textTokens = estimateTokensFromContent(fullContent); | ||
| // For images, estimate ~258 tokens per image + 1 token per 750 bytes | ||
| // This is based on Google's image token calculation | ||
| let imageTokens = 0; | ||
| if (imageByteSize > 0) { | ||
| // Base tokens per image (258) + additional tokens based on size | ||
| imageTokens = 258 + Math.ceil(imageByteSize / 750); | ||
| } | ||
| finalCompletionTokens = textTokens + imageTokens; | ||
| } | ||
|
Comment on lines
2507
to
2517
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. Image token estimation applies 258‑token base only once, regardless of number of images Across the streaming paths you now compute completion tokens as: // [DONE] path
let textTokens = estimateTokensFromContent(fullContent);
// ...
if (imageByteSize > 0) {
imageTokens = 258 + Math.ceil(imageByteSize / 750);
}
finalCompletionTokens = textTokens + imageTokens;and later: // finishReason-based estimation
let textTokens = estimateTokensFromContent(fullContent);
if (imageByteSize > 0) {
imageTokens = 258 + Math.ceil(imageByteSize / 750);
}
completionTokens = textTokens + imageTokens;and in if (!completionTokens && (fullContent || imageByteSize > 0)) {
// encode(JSON.stringify(fullContent)) or estimateTokensFromContent
// ...
if (imageByteSize > 0) {
imageTokens = 258 + Math.ceil(imageByteSize / 750);
}
calculatedCompletionTokens = textTokens + imageTokens;
}As in If a response can contain multiple images, this will under‑estimate completion tokens (and thus costs/limits). Consider:
const imageTokens =
imageCount > 0
? 258 * imageCount + Math.ceil(imageByteSize / 750)
: 0;
Also, for consistency, you may want to factor out a small helper like Also applies to: 2899-2905, 3015-3038 🤖 Prompt for AI Agents |
||
|
|
||
| if (finalTotalTokens === null) { | ||
|
|
@@ -2648,6 +2657,7 @@ chat.openapi(completions, async (c) => { | |
| data, | ||
| usedProvider, | ||
| fullContent, | ||
| imageByteSize, | ||
| ); | ||
|
|
||
| // If we have usage data from Google, add it to the streaming chunk | ||
|
|
@@ -2742,6 +2752,22 @@ chat.openapi(completions, async (c) => { | |
| } | ||
| } | ||
|
|
||
| // Track image data size for Google providers (for token estimation) | ||
| if ( | ||
| usedProvider === "google-ai-studio" || | ||
| usedProvider === "google-vertex" | ||
| ) { | ||
| const parts = data.candidates?.[0]?.content?.parts || []; | ||
| for (const part of parts) { | ||
| if (part.inlineData?.data) { | ||
| // Base64 string length * 0.75 ≈ actual byte size | ||
| imageByteSize += Math.ceil( | ||
| part.inlineData.data.length * 0.75, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+2755
to
+2769
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. 🧩 Analysis chainPotential double‑counting of image bytes across Google streaming chunks
if (
usedProvider === "google-ai-studio" ||
usedProvider === "google-vertex"
) {
const parts = data.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.data) {
// Base64 string length * 0.75 ≈ actual byte size
imageByteSize += Math.ceil(part.inlineData.data.length * 0.75);
}
}
}If Google’s streaming responses repeat the same You might want to:
Example minimal change: - for (const part of parts) {
- if (part.inlineData?.data) {
+ for (const part of parts) {
+ if (part.inlineData?.data && imageByteSize === 0) {I need to verify the context of this code to properly assess the double-counting concern. Let me explore the codebase structure. Length of output: 3553 🏁 Script executed: #!/bin/bash
# Search for where imageByteSize is declared and how it's used
rg -n "imageByteSize" apps/gateway/src/chat/chat.ts -B5 -A5 | head -100Length of output: 3954 🏁 Script executed: #!/bin/bash
# Look for Google streaming chunk processing to understand the loop
rg -n "google-ai-studio|google-vertex" apps/gateway/src/chat/chat.ts -B10 -A10 | head -150Length of output: 5604 🏁 Script executed: #!/bin/bash
# Check for SSE or streaming event handling
rg -n "for.*chunk|while.*chunk|\.on\(" apps/gateway/src/chat/chat.ts | head -30Length of output: 327 🏁 Script executed: #!/bin/bash
# Find function signature or context around line 2755
sed -n '2600,2770p' apps/gateway/src/chat/chat.ts | tail -200Length of output: 5452 I need to verify Google's streaming behavior and check for any existing deduplication logic. 🏁 Script executed: #!/bin/bash
# Check the extractTokenUsage function to see how imageByteSize is used
rg -n "function extractTokenUsage|const extractTokenUsage" apps/gateway/src/chat/chat.ts -A50 | head -80Length of output: 0 🏁 Script executed: #!/bin/bash
# Look for any image deduplication or tracking logic
rg -n "inlineData|image.*hash|image.*track" apps/gateway/src/chat/chat.ts -B3 -A3Length of output: 379 🏁 Script executed: #!/bin/bash
# Check how streaming chunks are processed - look for the loop structure
sed -n '2350,2420p' apps/gateway/src/chat/chat.tsLength of output: 2512 🏁 Script executed: #!/bin/bash
# Find where the streaming loop starts to understand chunk processing
rg -n "while.*buffer|for.*chunk" apps/gateway/src/chat/chat.ts -B5 -A15 | head -80Length of output: 3106 🌐 Web query: 💡 Result: Short answer: what you're seeing is expected — inlineData (binary/base64) can be split across multiple streaming chunks and may appear duplicated by transport buffering; you must treat the stream as a sequence of partial messages, reassemble/concatenate the inlineData pieces, and dedupe/ignore repeats before decoding. See below. Why it happens (brief)
How to handle it (recommended)
Sources
If you want, tell me which client/SDK (fetch, @google/genai, Python SDK, gRPC) you're using and I’ll give a short example showing robust parsing + inlineData reassembly for that environment. The web search confirms the double-counting concern is real. Google's streaming API can repeat inlineData across chunks. Let me verify the token estimation logic and the adequacy of the proposed fix. Length of output: 80 🏁 Script executed: #!/bin/bash
# Look for extractTokenUsage definition more broadly
grep -rn "extractTokenUsage.*=" apps/gateway/src/ --include="*.ts" --include="*.js" | head -20Length of output: 0 🏁 Script executed: #!/bin/bash
# Check how imageByteSize is used in token calculations
sed -n '2500,2530p' apps/gateway/src/chat/chat.tsLength of output: 1037 🏁 Script executed: #!/bin/bash
# Look for any part.id or image identification in Google response handling
rg -n "part\." apps/gateway/src/chat/chat.ts | grep -v "part of" | head -30Length of output: 94 🏁 Script executed: #!/bin/bash
# Check if there are multiple inlineData entries possible in a single chunk
rg -n "candidates\[0\]\.content\.parts" apps/gateway/src/chat/chat.ts -B3 -A5Length of output: 0 Let me examine the extractTokenUsage function and confirm whether the proposed fix is adequate. Length of output: 3055 🏁 Script executed: #!/bin/bash
# Look at surrounding context of the image byte size code to understand chunk processing better
sed -n '2740,2800p' apps/gateway/src/chat/chat.tsLength of output: 2130 🏁 Script executed: #!/bin/bash
# Check if data.candidates is queried for multiple indices or always [0]
rg -n "candidates\?" apps/gateway/src/chat/chat.ts | head -20Length of output: 133 Concern validated: double-counting of image bytes is possible, but proposed fix is overly simplistic Google's streaming API can repeat or split The suggested guard
Improved fix: - for (const part of parts) {
- if (part.inlineData?.data) {
+ for (const part of parts) {
+ if (part.inlineData?.data) {
+ // Deduplicate by checking if this base64 blob was already counted
+ const dataHash = crypto.createHash('sha256').update(part.inlineData.data).digest('hex');
+ if (!seenImageHashes.has(dataHash)) {
+ seenImageHashes.add(dataHash);Alternatively, if images only appear in the first chunk per Google's typical behavior, the simpler
|
||
|
|
||
| // Extract reasoning content for logging using helper function | ||
| // For providers with custom extraction logic (google-ai-studio, anthropic), | ||
| // use raw data. For others, use transformed OpenAI format. | ||
|
|
@@ -2834,7 +2860,12 @@ chat.openapi(completions, async (c) => { | |
| } | ||
|
|
||
| // Extract token usage using helper function | ||
| const usage = extractTokenUsage(data, usedProvider, fullContent); | ||
| const usage = extractTokenUsage( | ||
| data, | ||
| usedProvider, | ||
| fullContent, | ||
| imageByteSize, | ||
| ); | ||
| if (usage.promptTokens !== null) { | ||
| promptTokens = usage.promptTokens; | ||
| } | ||
|
|
@@ -2865,7 +2896,13 @@ chat.openapi(completions, async (c) => { | |
| } | ||
|
|
||
| if (!completionTokens) { | ||
| completionTokens = estimateTokensFromContent(fullContent); | ||
| let textTokens = estimateTokensFromContent(fullContent); | ||
| // For images, estimate ~258 tokens per image + 1 token per 750 bytes | ||
| let imageTokens = 0; | ||
| if (imageByteSize > 0) { | ||
| imageTokens = 258 + Math.ceil(imageByteSize / 750); | ||
| } | ||
| completionTokens = textTokens + imageTokens; | ||
| } | ||
|
|
||
| totalTokens = (promptTokens || 0) + (completionTokens || 0); | ||
|
|
@@ -2975,19 +3012,29 @@ chat.openapi(completions, async (c) => { | |
| } | ||
| } | ||
|
|
||
| if (!completionTokens && fullContent) { | ||
| if (!completionTokens && (fullContent || imageByteSize > 0)) { | ||
| try { | ||
| calculatedCompletionTokens = encode( | ||
| JSON.stringify(fullContent), | ||
| ).length; | ||
| let textTokens = fullContent | ||
| ? encode(JSON.stringify(fullContent)).length | ||
| : 0; | ||
| // For images, estimate ~258 tokens per image + 1 token per 750 bytes | ||
| let imageTokens = 0; | ||
| if (imageByteSize > 0) { | ||
| imageTokens = 258 + Math.ceil(imageByteSize / 750); | ||
| } | ||
| calculatedCompletionTokens = textTokens + imageTokens; | ||
| } catch (error) { | ||
| // Fallback to simple estimation if encoding fails | ||
| logger.error( | ||
| "Failed to encode completion text in streaming", | ||
| error instanceof Error ? error : new Error(String(error)), | ||
| ); | ||
| calculatedCompletionTokens = | ||
| estimateTokensFromContent(fullContent); | ||
| let textTokens = estimateTokensFromContent(fullContent); | ||
| let imageTokens = 0; | ||
| if (imageByteSize > 0) { | ||
| imageTokens = 258 + Math.ceil(imageByteSize / 750); | ||
| } | ||
| calculatedCompletionTokens = textTokens + imageTokens; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ export function extractTokenUsage( | |
| data: any, | ||
| provider: Provider, | ||
| fullContent?: string, | ||
| imageByteSize?: number, | ||
|
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. Image token estimation formula ignores image count vs comment (“per image”) The new
If Google responses can contain multiple images, this will under‑estimate completion tokens. Either:
Also applies to: 29-48 🤖 Prompt for AI Agents |
||
| ) { | ||
| let promptTokens = null; | ||
| let completionTokens = null; | ||
|
|
@@ -25,16 +26,25 @@ export function extractTokenUsage( | |
| // Don't use Google's totalTokenCount as it doesn't include reasoning tokens | ||
| reasoningTokens = data.usageMetadata.thoughtsTokenCount ?? null; | ||
|
|
||
| // If candidatesTokenCount is missing and we have content, estimate it | ||
| if (completionTokens === null && fullContent) { | ||
| // If candidatesTokenCount is missing and we have content or images, estimate it | ||
| if ( | ||
| completionTokens === null && | ||
| (fullContent || (imageByteSize && imageByteSize > 0)) | ||
| ) { | ||
| const estimation = estimateTokens( | ||
| provider, | ||
| [], | ||
| fullContent, | ||
| fullContent || "", | ||
| null, | ||
| null, | ||
| ); | ||
| completionTokens = estimation.calculatedCompletionTokens; | ||
| let textTokens = estimation.calculatedCompletionTokens || 0; | ||
| // For images, estimate ~258 tokens per image + 1 token per 750 bytes | ||
| let imageTokens = 0; | ||
| if (imageByteSize && imageByteSize > 0) { | ||
| imageTokens = 258 + Math.ceil(imageByteSize / 750); | ||
| } | ||
| completionTokens = textTokens + imageTokens; | ||
| } | ||
| // Calculate total including reasoning tokens (after potential estimation) | ||
| totalTokens = | ||
|
|
||
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.
Commented‑out MAX_BUFFER_SIZE removes protection against unbounded streaming buffer growth
You’ve left
bufferunbounded by commenting out the 10MB guard:This means a misbehaving or malicious upstream stream that never yields a complete SSE event can grow
bufferwithout limit, risking high memory usage or OOM under load.Consider re‑enabling the guard (even at a higher limit or gated behind
debugMode) so production still has a hard cap, for example:Also applies to: 2335-2342
🤖 Prompt for AI Agents