Skip to content

Commit d9baf9e

Browse files
authored
Add support for token-based usage metrics (#6658)
Token measurement is broken down by modaliy, with separate counters for image, audio, etc. Tests are in version 6.*, so this change also includes bumping update_responses.sh
1 parent 336cf32 commit d9baf9e

File tree

9 files changed

+209
-16
lines changed

9 files changed

+209
-16
lines changed

firebase-vertexai/CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Unreleased
2-
2+
* [changed] Added support for modality-based token count. (#6658)
33

44
# 16.1.0
55
* [changed] Internal improvements to correctly handle empty model responses.
@@ -64,4 +64,3 @@
6464
* [feature] Added support for `responseMimeType` in `GenerationConfig`.
6565
* [changed] Renamed `GoogleGenerativeAIException` to `FirebaseVertexAIException`.
6666
* [changed] Updated the KDocs for various classes and functions.
67-

firebase-vertexai/api.txt

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,30 @@ package com.google.firebase.vertexai.type {
165165
method public static com.google.firebase.vertexai.type.Content content(String? role = "user", kotlin.jvm.functions.Function1<? super com.google.firebase.vertexai.type.Content.Builder,kotlin.Unit> init);
166166
}
167167

168+
public final class ContentModality {
169+
method public int getOrdinal();
170+
property public final int ordinal;
171+
field public static final com.google.firebase.vertexai.type.ContentModality AUDIO;
172+
field public static final com.google.firebase.vertexai.type.ContentModality.Companion Companion;
173+
field public static final com.google.firebase.vertexai.type.ContentModality DOCUMENT;
174+
field public static final com.google.firebase.vertexai.type.ContentModality IMAGE;
175+
field public static final com.google.firebase.vertexai.type.ContentModality TEXT;
176+
field public static final com.google.firebase.vertexai.type.ContentModality UNSPECIFIED;
177+
field public static final com.google.firebase.vertexai.type.ContentModality VIDEO;
178+
}
179+
180+
public static final class ContentModality.Companion {
181+
}
182+
168183
public final class CountTokensResponse {
169-
ctor public CountTokensResponse(int totalTokens, Integer? totalBillableCharacters = null);
184+
ctor public CountTokensResponse(int totalTokens, Integer? totalBillableCharacters = null, java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? promptTokensDetails = null);
170185
method public operator int component1();
171186
method public operator Integer? component2();
187+
method public operator java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? component3();
188+
method public java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? getPromptTokensDetails();
172189
method public Integer? getTotalBillableCharacters();
173190
method public int getTotalTokens();
191+
property public final java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? promptTokensDetails;
174192
property public final Integer? totalBillableCharacters;
175193
property public final int totalTokens;
176194
}
@@ -369,6 +387,15 @@ package com.google.firebase.vertexai.type {
369387
public final class InvalidStateException extends com.google.firebase.vertexai.type.FirebaseVertexAIException {
370388
}
371389

390+
public final class ModalityTokenCount {
391+
method public operator com.google.firebase.vertexai.type.ContentModality component1();
392+
method public operator int component2();
393+
method public com.google.firebase.vertexai.type.ContentModality getModality();
394+
method public int getTokenCount();
395+
property public final com.google.firebase.vertexai.type.ContentModality modality;
396+
property public final int tokenCount;
397+
}
398+
372399
public interface Part {
373400
}
374401

@@ -549,12 +576,16 @@ package com.google.firebase.vertexai.type {
549576
}
550577

551578
public final class UsageMetadata {
552-
ctor public UsageMetadata(int promptTokenCount, Integer? candidatesTokenCount, int totalTokenCount);
579+
ctor public UsageMetadata(int promptTokenCount, Integer? candidatesTokenCount, int totalTokenCount, java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? promptTokensDetails, java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? candidatesTokensDetails);
553580
method public Integer? getCandidatesTokenCount();
581+
method public java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? getCandidatesTokensDetails();
554582
method public int getPromptTokenCount();
583+
method public java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? getPromptTokensDetails();
555584
method public int getTotalTokenCount();
556585
property public final Integer? candidatesTokenCount;
586+
property public final java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? candidatesTokensDetails;
557587
property public final int promptTokenCount;
588+
property public final java.util.List<com.google.firebase.vertexai.type.ModalityTokenCount>? promptTokensDetails;
558589
property public final int totalTokenCount;
559590
}
560591

firebase-vertexai/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
version=16.1.1
15+
version=16.2.0
1616
latestReleasedVersion=16.1.0
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.vertexai.type
18+
19+
import com.google.firebase.vertexai.common.util.FirstOrdinalSerializer
20+
import kotlinx.serialization.KSerializer
21+
import kotlinx.serialization.SerialName
22+
import kotlinx.serialization.Serializable
23+
24+
/** Content part modality. */
25+
public class ContentModality private constructor(public val ordinal: Int) {
26+
27+
@Serializable(Internal.Serializer::class)
28+
internal enum class Internal {
29+
@SerialName("MODALITY_UNSPECIFIED") UNSPECIFIED,
30+
TEXT,
31+
IMAGE,
32+
VIDEO,
33+
AUDIO,
34+
DOCUMENT;
35+
36+
internal object Serializer : KSerializer<Internal> by FirstOrdinalSerializer(Internal::class)
37+
38+
internal fun toPublic() =
39+
when (this) {
40+
TEXT -> ContentModality.TEXT
41+
IMAGE -> ContentModality.IMAGE
42+
VIDEO -> ContentModality.VIDEO
43+
AUDIO -> ContentModality.AUDIO
44+
DOCUMENT -> ContentModality.DOCUMENT
45+
else -> ContentModality.UNSPECIFIED
46+
}
47+
}
48+
49+
public companion object {
50+
/** Unspecified modality. */
51+
@JvmField public val UNSPECIFIED: ContentModality = ContentModality(0)
52+
53+
/** Plain text. */
54+
@JvmField public val TEXT: ContentModality = ContentModality(1)
55+
56+
/** Image. */
57+
@JvmField public val IMAGE: ContentModality = ContentModality(2)
58+
59+
/** Video. */
60+
@JvmField public val VIDEO: ContentModality = ContentModality(3)
61+
62+
/** Audio. */
63+
@JvmField public val AUDIO: ContentModality = ContentModality(4)
64+
65+
/** Document, e.g. PDF. */
66+
@JvmField public val DOCUMENT: ContentModality = ContentModality(5)
67+
}
68+
}

firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,33 @@ import kotlinx.serialization.Serializable
3030
* to the model as a prompt. **Important:** this property does not include billable image, video or
3131
* other non-text input. See
3232
* [Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing) for details.
33+
* @property promptTokensDetails The breakdown, by modality, of how many tokens are consumed by the
34+
* prompt.
3335
*/
3436
public class CountTokensResponse(
3537
public val totalTokens: Int,
36-
public val totalBillableCharacters: Int? = null
38+
public val totalBillableCharacters: Int? = null,
39+
public val promptTokensDetails: List<ModalityTokenCount>? = null,
3740
) {
3841
public operator fun component1(): Int = totalTokens
3942

4043
public operator fun component2(): Int? = totalBillableCharacters
4144

45+
public operator fun component3(): List<ModalityTokenCount>? = promptTokensDetails
46+
4247
@Serializable
43-
internal data class Internal(val totalTokens: Int, val totalBillableCharacters: Int? = null) :
44-
Response {
48+
internal data class Internal(
49+
val totalTokens: Int,
50+
val totalBillableCharacters: Int? = null,
51+
val promptTokensDetails: List<ModalityTokenCount.Internal>? = null
52+
) : Response {
4553

4654
internal fun toPublic(): CountTokensResponse {
47-
return CountTokensResponse(totalTokens, totalBillableCharacters ?: 0)
55+
return CountTokensResponse(
56+
totalTokens,
57+
totalBillableCharacters ?: 0,
58+
promptTokensDetails?.map { it.toPublic() }
59+
)
4860
}
4961
}
5062
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.vertexai.type
18+
19+
import kotlinx.serialization.Serializable
20+
21+
/**
22+
* Represents token counting info for a single modality.
23+
*
24+
* @property modality The modality associated with this token count.
25+
* @property tokenCount The number of tokens counted.
26+
*/
27+
public class ModalityTokenCount
28+
private constructor(public val modality: ContentModality, public val tokenCount: Int) {
29+
30+
public operator fun component1(): ContentModality = modality
31+
32+
public operator fun component2(): Int = tokenCount
33+
34+
@Serializable
35+
internal data class Internal(
36+
val modality: ContentModality.Internal,
37+
val tokenCount: Int? = null
38+
) {
39+
internal fun toPublic() = ModalityTokenCount(modality.toPublic(), tokenCount ?: 0)
40+
}
41+
}

firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/UsageMetadata.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,35 @@ import kotlinx.serialization.Serializable
2424
* @param promptTokenCount Number of tokens in the request.
2525
* @param candidatesTokenCount Number of tokens in the response(s).
2626
* @param totalTokenCount Total number of tokens.
27+
* @param promptTokensDetails The breakdown, by modality, of how many tokens are consumed by the
28+
* prompt.
29+
* @param candidatesTokensDetails The breakdown, by modality, of how many tokens are consumed by the
30+
* candidates.
2731
*/
2832
public class UsageMetadata(
2933
public val promptTokenCount: Int,
3034
public val candidatesTokenCount: Int?,
31-
public val totalTokenCount: Int
35+
public val totalTokenCount: Int,
36+
public val promptTokensDetails: List<ModalityTokenCount>?,
37+
public val candidatesTokensDetails: List<ModalityTokenCount>?,
3238
) {
3339

3440
@Serializable
3541
internal data class Internal(
3642
val promptTokenCount: Int? = null,
3743
val candidatesTokenCount: Int? = null,
3844
val totalTokenCount: Int? = null,
45+
val promptTokensDetails: List<ModalityTokenCount.Internal>? = null,
46+
val candidatesTokensDetails: List<ModalityTokenCount.Internal>? = null,
3947
) {
4048

4149
internal fun toPublic(): UsageMetadata =
42-
UsageMetadata(promptTokenCount ?: 0, candidatesTokenCount ?: 0, totalTokenCount ?: 0)
50+
UsageMetadata(
51+
promptTokenCount ?: 0,
52+
candidatesTokenCount ?: 0,
53+
totalTokenCount ?: 0,
54+
promptTokensDetails = promptTokensDetails?.map { it.toPublic() },
55+
candidatesTokensDetails = candidatesTokensDetails?.map { it.toPublic() }
56+
)
4357
}
4458
}

firebase-vertexai/src/test/java/com/google/firebase/vertexai/UnarySnapshotTests.kt

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.google.firebase.vertexai
1818

1919
import com.google.firebase.vertexai.type.BlockReason
20+
import com.google.firebase.vertexai.type.ContentModality
2021
import com.google.firebase.vertexai.type.FinishReason
2122
import com.google.firebase.vertexai.type.FunctionCallPart
2223
import com.google.firebase.vertexai.type.HarmCategory
@@ -34,7 +35,6 @@ import com.google.firebase.vertexai.util.goldenUnaryFile
3435
import com.google.firebase.vertexai.util.shouldNotBeNullOrEmpty
3536
import io.kotest.assertions.throwables.shouldThrow
3637
import io.kotest.inspectors.forAtLeastOne
37-
import io.kotest.matchers.collections.shouldContain
3838
import io.kotest.matchers.collections.shouldNotBeEmpty
3939
import io.kotest.matchers.nulls.shouldNotBeNull
4040
import io.kotest.matchers.should
@@ -70,15 +70,27 @@ internal class UnarySnapshotTests {
7070
}
7171

7272
@Test
73-
fun `long reply`() =
74-
goldenUnaryFile("unary-success-basic-reply-long.json") {
73+
fun `response with detailed token-based usageMetadata`() =
74+
goldenUnaryFile("unary-success-basic-response-long-usage-metadata.json") {
7575
withTimeout(testTimeout) {
7676
val response = model.generateContent("prompt")
7777

7878
response.candidates.isEmpty() shouldBe false
7979
response.candidates.first().finishReason shouldBe FinishReason.STOP
8080
response.candidates.first().content.parts.isEmpty() shouldBe false
81-
response.candidates.first().safetyRatings.isEmpty() shouldBe false
81+
response.usageMetadata shouldNotBe null
82+
response.usageMetadata?.apply {
83+
totalTokenCount shouldBe 1913
84+
candidatesTokenCount shouldBe 76
85+
promptTokensDetails?.forAtLeastOne {
86+
it.modality shouldBe ContentModality.IMAGE
87+
it.tokenCount shouldBe 1806
88+
}
89+
candidatesTokensDetails?.forAtLeastOne {
90+
it.modality shouldBe ContentModality.TEXT
91+
it.tokenCount shouldBe 76
92+
}
93+
}
8294
}
8395
}
8496

@@ -469,6 +481,22 @@ internal class UnarySnapshotTests {
469481
}
470482
}
471483

484+
@Test
485+
fun `countTokens with modality fields returned`() =
486+
goldenUnaryFile("unary-success-detailed-token-response.json") {
487+
withTimeout(testTimeout) {
488+
val response = model.countTokens("prompt")
489+
490+
response.totalTokens shouldBe 1837
491+
response.totalBillableCharacters shouldBe 117
492+
response.promptTokensDetails shouldNotBe null
493+
response.promptTokensDetails?.forAtLeastOne {
494+
it.modality shouldBe ContentModality.IMAGE
495+
it.tokenCount shouldBe 1806
496+
}
497+
}
498+
}
499+
472500
@Test
473501
fun `countTokens succeeds with no billable characters`() =
474502
goldenUnaryFile("unary-success-no-billable-characters.json") {

firebase-vertexai/update_responses.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
# This script replaces mock response files for Vertex AI unit tests with a fresh
1818
# clone of the shared repository of Vertex AI test data.
1919

20-
RESPONSES_VERSION='v5.*' # The major version of mock responses to use
20+
RESPONSES_VERSION='v6.*' # The major version of mock responses to use
2121
REPO_NAME="vertexai-sdk-test-data"
2222
REPO_LINK="https://github.com/FirebaseExtended/$REPO_NAME.git"
2323

0 commit comments

Comments
 (0)