Skip to content

Commit eb9b668

Browse files
authored
feat: Add token usage tracking to OpenAI requests (#466)
* Add token usage tracking to OpenAI requests * Use model_dump() for complete token usage details * Changelog update * CHANGELOG update
1 parent f16b486 commit eb9b668

6 files changed

Lines changed: 146 additions & 5 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ v3.8.1
66

77
*Release date: In development*
88

9+
- Add token usage tracking to OpenAI requests
10+
911
v3.8.0
1012
------
1113

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Copyright 2026 Telefónica Innovación Digital, S.L.
3+
This file is part of Toolium.
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
"""
17+
18+
import os
19+
from unittest import mock
20+
21+
import pytest
22+
23+
from toolium.test.utils.ai_utils.common import (
24+
configure_default_openai_model, # noqa: F401, fixture needed to set the OpenAI model for all tests in this module
25+
)
26+
from toolium.utils.ai_utils.openai import openai_request
27+
28+
29+
def _build_mock_completion(content='test response', prompt_tokens=10, completion_tokens=5, total_tokens=15):
30+
"""Build a mock OpenAI completion object with usage data"""
31+
usage_dump = {
32+
'prompt_tokens': prompt_tokens,
33+
'completion_tokens': completion_tokens,
34+
'total_tokens': total_tokens,
35+
'completion_tokens_details': {
36+
'accepted_prediction_tokens': 0,
37+
'audio_tokens': 0,
38+
'reasoning_tokens': 0,
39+
'rejected_prediction_tokens': 0,
40+
},
41+
'prompt_tokens_details': {
42+
'audio_tokens': 0,
43+
'cached_tokens': 0,
44+
},
45+
}
46+
47+
mock_usage = mock.MagicMock()
48+
mock_usage.prompt_tokens = prompt_tokens
49+
mock_usage.completion_tokens = completion_tokens
50+
mock_usage.total_tokens = total_tokens
51+
mock_usage.model_dump.return_value = usage_dump
52+
53+
mock_message = mock.MagicMock()
54+
mock_message.content = content
55+
56+
mock_choice = mock.MagicMock()
57+
mock_choice.message = mock_message
58+
59+
mock_completion = mock.MagicMock()
60+
mock_completion.choices = [mock_choice]
61+
mock_completion.usage = mock_usage
62+
return mock_completion
63+
64+
65+
@mock.patch('toolium.utils.ai_utils.openai.OpenAI')
66+
def test_openai_request_returns_token_usage(mock_openai_class):
67+
mock_client = mock.MagicMock()
68+
mock_openai_class.return_value = mock_client
69+
mock_client.chat.completions.create.return_value = _build_mock_completion(
70+
content='hello', prompt_tokens=20, completion_tokens=10, total_tokens=30
71+
)
72+
73+
response, token_usage = openai_request('system', 'user')
74+
75+
assert response == 'hello'
76+
assert token_usage['prompt_tokens'] == 20
77+
assert token_usage['completion_tokens'] == 10
78+
assert token_usage['total_tokens'] == 30
79+
assert 'completion_tokens_details' in token_usage
80+
assert 'prompt_tokens_details' in token_usage
81+
82+
83+
@mock.patch('toolium.utils.ai_utils.openai.OpenAI')
84+
def test_openai_request_returns_empty_token_usage_when_no_usage(mock_openai_class):
85+
mock_client = mock.MagicMock()
86+
mock_openai_class.return_value = mock_client
87+
mock_completion = _build_mock_completion()
88+
mock_completion.usage = None
89+
mock_client.chat.completions.create.return_value = mock_completion
90+
91+
response, token_usage = openai_request('system', 'user')
92+
93+
assert response == 'test response'
94+
assert token_usage == {}
95+
96+
97+
@mock.patch('toolium.utils.ai_utils.openai.OpenAI')
98+
def test_openai_request_with_response_format_returns_token_usage(mock_openai_class):
99+
mock_client = mock.MagicMock()
100+
mock_openai_class.return_value = mock_client
101+
102+
mock_parsed = mock.MagicMock()
103+
mock_message = mock.MagicMock()
104+
mock_message.parsed = mock_parsed
105+
mock_choice = mock.MagicMock()
106+
mock_choice.message = mock_message
107+
mock_completion = _build_mock_completion(prompt_tokens=50, completion_tokens=25, total_tokens=75)
108+
mock_completion.choices = [mock_choice]
109+
mock_client.beta.chat.completions.parse.return_value = mock_completion
110+
111+
response, token_usage = openai_request('system', 'user', response_format=mock.MagicMock())
112+
113+
assert response is mock_parsed
114+
assert token_usage['prompt_tokens'] == 50
115+
assert token_usage['completion_tokens'] == 25
116+
assert token_usage['total_tokens'] == 75
117+
118+
119+
@pytest.mark.skipif(not os.getenv('AZURE_OPENAI_API_KEY'), reason='AZURE_OPENAI_API_KEY environment variable not set')
120+
def test_openai_request_returns_token_usage_with_azure():
121+
response, token_usage = openai_request('You are a helpful assistant.', 'Say hello.', azure=True)
122+
123+
assert isinstance(response, str)
124+
assert len(response) > 0
125+
assert token_usage['prompt_tokens'] > 0
126+
assert token_usage['completion_tokens'] > 0
127+
assert token_usage['total_tokens'] > 0
128+
assert 'completion_tokens_details' in token_usage
129+
assert 'prompt_tokens_details' in token_usage

toolium/utils/ai_utils/evaluate_answer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def get_answer_evaluation_with_openai(
116116
if response_format:
117117
kwargs['response_format'] = response_format
118118

119-
response = openai_request(system_message, user_message, model_name, azure, **kwargs)
119+
response, _ = openai_request(system_message, user_message, model_name, azure, **kwargs)
120120

121121
try:
122122
if response_format and hasattr(response, 'similarity'):

toolium/utils/ai_utils/openai.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def openai_request(system_message, user_message, model_name=None, azure=False, *
3838
:param model_name: name of the model to use
3939
:param azure: whether to use Azure OpenAI or standard OpenAI
4040
:param kwargs: additional parameters to be passed to the OpenAI client (azure_endpoint, timeout, etc.)
41-
:returns: response from OpenAI
41+
:returns: tuple with response from OpenAI and token usage dict
4242
"""
4343
if OpenAI is None:
4444
raise ImportError("OpenAI is not installed. Please run 'pip install toolium[ai]' to use OpenAI features")
@@ -67,5 +67,14 @@ def openai_request(system_message, user_message, model_name=None, azure=False, *
6767
else:
6868
completion = client.chat.completions.create(model=model_name, messages=messages)
6969
response = completion.choices[0].message.content
70+
token_usage = {}
71+
if completion.usage:
72+
token_usage = completion.usage.model_dump()
73+
logger.info(
74+
'OpenAI token usage: prompt_tokens=%d, completion_tokens=%d, total_tokens=%d',
75+
completion.usage.prompt_tokens,
76+
completion.usage.completion_tokens,
77+
completion.usage.total_tokens,
78+
)
7079
logger.debug('OpenAI response: %s', response)
71-
return response
80+
return response, token_usage

toolium/utils/ai_utils/text_analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ def get_text_criteria_analysis(text_input, text_criteria, model_name=None, azure
8383
"""
8484
# Build prompt using base prompt and target features
8585
system_message = build_system_message(text_criteria)
86-
return openai_request(system_message, text_input, model_name, azure, **kwargs)
86+
response, _ = openai_request(system_message, text_input, model_name, azure, **kwargs)
87+
return response
8788

8889

8990
def assert_text_criteria(text_input, text_criteria, threshold, model_name=None, azure=False, **kwargs):

toolium/utils/ai_utils/text_similarity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def get_text_similarity_with_openai(text, expected_text, model_name=None, azure=
102102
' but its meaning should be similar.'
103103
)
104104
user_message = f'The expected answer is: {expected_text}. The LLM answer is: {text}.'
105-
response = openai_request(system_message, user_message, model_name, azure, **kwargs)
105+
response, _ = openai_request(system_message, user_message, model_name, azure, **kwargs)
106106
try:
107107
response = json.loads(response)
108108
similarity = float(response['similarity'])

0 commit comments

Comments
 (0)