Skip to content

Commit 2f6be15

Browse files
authored
Set token usage for gemini streaming (#2302)
Fixes #2176. The new test fails before my changes, but passes after. <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Add token usage tracking for Gemini streaming in `scan_google_response_stream` and introduce tests to verify functionality. > > - **Behavior**: > - Set token usage in `scan_google_response_stream` in `response_handler.rs` for Gemini streaming. > - Adds `prompt_tokens`, `output_tokens`, and `total_tokens` to `inner.metadata`. > - **Tests**: > - Adds `test_flash25_streaming_deserialization` in `response_handler.rs` to verify deserialization of Gemini streaming response. > - Adds `test_collector_async_stream_success_gemini` in `test_collector.py` to verify token usage logging for Gemini streaming. > - **Misc**: > - Minor formatting changes in `test_collector.py`. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=BoundaryML%2Fbaml&utm_source=github&utm_medium=referral)<sup> for 085b5e9. You can [customize](https://app.ellipsis.dev/BoundaryML/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN -->
1 parent 934ad56 commit 2f6be15

2 files changed

Lines changed: 151 additions & 21 deletions

File tree

‎engine/baml-runtime/src/internal/llm_client/primitive/google/response_handler.rs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ pub fn scan_google_response_stream(
168168
}
169169
}
170170

171+
inner.metadata.prompt_tokens = event.usage_metadata.prompt_token_count;
172+
inner.metadata.output_tokens = event.usage_metadata.candidates_token_count;
173+
inner.metadata.total_tokens = event.usage_metadata.total_token_count;
174+
171175
inner.latency = instant_now.elapsed();
172176
Ok(())
173177
}
@@ -220,6 +224,39 @@ mod tests {
220224
}
221225
"#;
222226

227+
const FLASH_25_RESPONSE_STREAMING_RESPONSE: &str = r#"
228+
{
229+
"candidates": [
230+
{
231+
"content": {
232+
"parts": [
233+
{
234+
"text": "I need the grounding documents to summarize them. Please provide the documents related to the query \"why not?\"."
235+
}
236+
],
237+
"role": "model"
238+
},
239+
"finishReason": "STOP",
240+
"index": 0
241+
}
242+
],
243+
"usageMetadata": {
244+
"promptTokenCount": 135,
245+
"candidatesTokenCount": 21,
246+
"totalTokenCount": 404,
247+
"promptTokensDetails": [
248+
{
249+
"modality": "TEXT",
250+
"tokenCount": 135
251+
}
252+
],
253+
"thoughtsTokenCount": 248
254+
},
255+
"modelVersion": "gemini-2.5-flash",
256+
"responseId": "THqbaJaMOuCajMcP54G4qAE"
257+
}
258+
"#;
259+
223260
#[test]
224261
fn test_json_deserialization() {
225262
let response: GoogleResponse = serde_json::from_str(RESPONSE).unwrap();
@@ -256,6 +293,12 @@ mod tests {
256293
assert_eq!(response_json, expected_json);
257294
}
258295

296+
#[test]
297+
298+
fn test_flash25_streaming_deserialization() {
299+
let _: GoogleResponse = serde_json::from_str(FLASH_25_RESPONSE_STREAMING_RESPONSE).unwrap();
300+
}
301+
259302
#[test]
260303
fn test_parse_google_response() {
261304
let client = MockClient::new();

‎integ-tests/python/tests/test_collector.py‎

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,89 @@ async def test_collector_async_stream_success():
223223
assert function_call_count() > 0
224224

225225

226+
@pytest.mark.asyncio
227+
async def test_collector_async_stream_success_gemini():
228+
collector = Collector(name="my-collector")
229+
function_logs = collector.logs
230+
assert len(function_logs) == 0
231+
232+
stream = b.stream.TestGemini("hi there", baml_options={"collector": collector})
233+
234+
async for chunk in stream:
235+
print(f"### chunk: {chunk}")
236+
237+
res = await stream.get_final_response()
238+
print(f"### res: {res}")
239+
function_logs = collector.logs
240+
241+
function_logs = collector.logs
242+
assert len(function_logs) == 1
243+
244+
log = collector.last
245+
assert log is not None
246+
assert log.function_name == "TestGemini"
247+
assert log.log_type == "stream"
248+
249+
function_logs = collector.logs
250+
assert len(function_logs) == 1
251+
252+
log = collector.last
253+
assert log is not None
254+
assert log.function_name == "TestGemini"
255+
assert log.log_type == "stream"
256+
257+
# Verify timing fields
258+
assert log.timing.start_time_utc_ms > 0
259+
assert log.timing.duration_ms is not None and log.timing.duration_ms > 0
260+
261+
# Verify usage fields
262+
assert log.usage.input_tokens is not None and log.usage.input_tokens > 0
263+
assert log.usage.output_tokens is not None and log.usage.output_tokens > 0
264+
265+
# Verify calls
266+
calls = log.calls
267+
assert len(calls) == 1
268+
269+
call = calls[0]
270+
271+
assert call.provider == "google-ai"
272+
assert call.client_name == "Gemini"
273+
assert call.selected
274+
275+
# Verify request/response
276+
request = call.http_request
277+
assert request is not None
278+
print(f"### request.body: {request.body} \n {type(request.body)}", file=sys.stderr)
279+
body = request.body.json()
280+
281+
print(f"{call.usage}")
282+
283+
assert isinstance(body, dict)
284+
assert "contents" in body
285+
286+
# Verify http response
287+
response = call.http_response
288+
assert response is None
289+
290+
# Verify call timing
291+
call_timing = call.timing
292+
assert call_timing.start_time_utc_ms > 0
293+
assert call_timing.duration_ms is not None and call_timing.duration_ms > 0
294+
295+
# Verify call usage
296+
call_usage = call.usage
297+
assert call_usage.input_tokens is not None and call_usage.input_tokens > 0
298+
assert call_usage.output_tokens is not None and call_usage.output_tokens > 0
299+
300+
# Verify raw response exists
301+
assert log.raw_llm_response is not None
302+
303+
gc.collect()
304+
print("----- gc.collect() -----", file=sys.stderr)
305+
# still not collected cause it's in use
306+
assert function_call_count() > 0
307+
308+
226309
@pytest.mark.asyncio
227310
async def test_collector_async_multiple_calls_usage():
228311
collector = Collector(name="my-collector")
@@ -890,7 +973,9 @@ async def test_collector_openai_stream_chunk_verification():
890973
assert call.client_name == "GPT4oMini"
891974
sse_chunks = call.sse_responses()
892975
assert sse_chunks is not None
893-
assert len(sse_chunks) >= len(chunks_received), f"Expected {len(chunks_received)} chunks, got {sse_chunks}"
976+
assert len(sse_chunks) >= len(
977+
chunks_received
978+
), f"Expected {len(chunks_received)} chunks, got {sse_chunks}"
894979
for chunk in sse_chunks:
895980
print(f"Chunk: {chunk.json()}")
896981

@@ -907,52 +992,52 @@ async def test_collector_openai_stream_chunk_verification():
907992
async def test_collector_openai_multiple_concurrent_streams():
908993
"""Test streaming collector with multiple concurrent OpenAI streams"""
909994
collector = Collector(name="openai-concurrent-streams")
910-
995+
911996
async def stream_and_collect(prompt: str) -> tuple[list[str], str]:
912997
"""Helper to run a stream and collect chunks"""
913998
chunks = []
914999
stream = b.stream.TestOpenAIGPT4oMini(
9151000
prompt, baml_options={"collector": collector}
9161001
)
917-
1002+
9181003
async for chunk in stream:
9191004
chunks.append(chunk)
920-
1005+
9211006
final = await stream.get_final_response()
9221007
return chunks, final
923-
1008+
9241009
# Run multiple streams concurrently
9251010
results = await asyncio.gather(
9261011
stream_and_collect("Say hello"),
9271012
stream_and_collect("Say goodbye"),
928-
stream_and_collect("Count to 3")
1013+
stream_and_collect("Count to 3"),
9291014
)
930-
1015+
9311016
# Verify we got results from all streams
9321017
assert len(results) == 3
9331018
for chunks, final in results:
9341019
assert len(chunks) > 0
9351020
assert len(final) > 0
936-
1021+
9371022
# Verify collector captured all streams
9381023
logs = collector.logs
9391024
assert len(logs) == 3
940-
1025+
9411026
# Verify each log is properly formed
9421027
for i, log in enumerate(logs):
9431028
assert log.function_name == "TestOpenAIGPT4oMini"
9441029
assert log.log_type == "stream"
9451030
assert log.timing.duration_ms is not None and log.timing.duration_ms > 0
9461031
assert log.usage.input_tokens is not None and log.usage.input_tokens > 0
9471032
assert log.usage.output_tokens is not None and log.usage.output_tokens > 0
948-
1033+
9491034
# Verify streaming request
9501035
call = log.calls[0]
9511036
assert call.provider == "openai"
9521037
assert call.http_request
9531038
request_body = call.http_request.body.json()
9541039
assert request_body.get("stream") is True
955-
1040+
9561041
# Verify total usage is sum of all streams
9571042
total_input = sum(log.usage.input_tokens or 0 for log in logs)
9581043
total_output = sum(log.usage.output_tokens or 0 for log in logs)
@@ -964,56 +1049,58 @@ async def stream_and_collect(prompt: str) -> tuple[list[str], str]:
9641049
async def test_collector_openai_stream_usage_accumulation():
9651050
"""Test that streaming collector properly accumulates usage across multiple calls"""
9661051
collector = Collector(name="openai-stream-usage")
967-
1052+
9681053
# First streaming call
9691054
stream1 = b.stream.TestOpenAIGPT4oMini(
9701055
"First stream", baml_options={"collector": collector}
9711056
)
9721057
async for _ in stream1:
9731058
pass
9741059
await stream1.get_final_response()
975-
1060+
9761061
# Capture usage after first stream
9771062
first_usage = collector.usage
9781063
first_input = first_usage.input_tokens
9791064
first_output = first_usage.output_tokens
9801065
assert first_input is not None and first_input > 0
9811066
assert first_output is not None and first_output > 0
982-
1067+
9831068
# Second streaming call
9841069
stream2 = b.stream.TestOpenAIGPT4oMini(
9851070
"Second stream with more content", baml_options={"collector": collector}
9861071
)
9871072
async for _ in stream2:
9881073
pass
9891074
await stream2.get_final_response()
990-
1075+
9911076
# Verify usage accumulated
9921077
assert collector.usage.input_tokens is not None
9931078
assert collector.usage.output_tokens is not None
9941079
assert collector.usage.input_tokens > first_input
9951080
assert collector.usage.output_tokens > first_output
9961081
assert collector.usage.input_tokens > 0
9971082
assert collector.usage.output_tokens > 0
998-
1083+
9991084
# Non-streaming call
1000-
await b.TestOpenAIGPT4oMini("Non-streaming call", baml_options={"collector": collector})
1001-
1085+
await b.TestOpenAIGPT4oMini(
1086+
"Non-streaming call", baml_options={"collector": collector}
1087+
)
1088+
10021089
# Verify we have 3 logs total
10031090
logs = collector.logs
10041091
assert len(logs) == 3
1005-
1092+
10061093
# Verify total usage matches sum of individual calls
10071094
total_input = sum(log.usage.input_tokens or 0 for log in logs)
10081095
total_output = sum(log.usage.output_tokens or 0 for log in logs)
10091096
assert collector.usage.input_tokens == total_input
10101097
assert collector.usage.output_tokens == total_output
1011-
1098+
10121099
# Verify first two are streaming, last is not
10131100
for i in range(2):
10141101
request_body = logs[i].calls[0].http_request.body.json()
10151102
assert request_body.get("stream") is True
1016-
1103+
10171104
# Last call should not be streaming
10181105
last_request_body = logs[2].calls[0].http_request.body.json()
10191106
assert last_request_body.get("stream") is not True

0 commit comments

Comments
 (0)