Skip to content

Commit 8e0d575

Browse files
authored
Merge pull request #1751 from newrelic/feat-openai-token-counting
OpenAI Token Counting
2 parents 18602dd + 5d4783a commit 8e0d575

17 files changed

Lines changed: 572 additions & 682 deletions

newrelic/hooks/mlmodel_openai.py

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,11 @@ def create_chat_completion_message_event(
133133
span_id,
134134
trace_id,
135135
response_model,
136-
request_model,
137136
response_id,
138137
request_id,
139138
llm_metadata,
140139
output_message_list,
140+
all_token_counts,
141141
request_timestamp=None,
142142
):
143143
settings = transaction.settings if transaction.settings is not None else global_settings()
@@ -158,20 +158,20 @@ def create_chat_completion_message_event(
158158
"request_id": request_id,
159159
"span_id": span_id,
160160
"trace_id": trace_id,
161-
"token_count": (
162-
settings.ai_monitoring.llm_token_count_callback(request_model, message_content)
163-
if settings.ai_monitoring.llm_token_count_callback
164-
else None
165-
),
166161
"role": message.get("role"),
167162
"completion_id": chat_completion_id,
168163
"sequence": index,
169164
"response.model": response_model,
170165
"vendor": "openai",
171166
"ingest_source": "Python",
172167
}
168+
173169
if settings.ai_monitoring.record_content.enabled and message_content:
174170
chat_completion_input_message_dict["content"] = message_content
171+
172+
if all_token_counts:
173+
chat_completion_input_message_dict["token_count"] = 0
174+
175175
if request_timestamp:
176176
chat_completion_input_message_dict["timestamp"] = request_timestamp
177177

@@ -199,11 +199,6 @@ def create_chat_completion_message_event(
199199
"request_id": request_id,
200200
"span_id": span_id,
201201
"trace_id": trace_id,
202-
"token_count": (
203-
settings.ai_monitoring.llm_token_count_callback(response_model, message_content)
204-
if settings.ai_monitoring.llm_token_count_callback
205-
else None
206-
),
207202
"role": message.get("role"),
208203
"completion_id": chat_completion_id,
209204
"sequence": index,
@@ -216,6 +211,9 @@ def create_chat_completion_message_event(
216211
if settings.ai_monitoring.record_content.enabled and message_content:
217212
chat_completion_output_message_dict["content"] = message_content
218213

214+
if all_token_counts:
215+
chat_completion_output_message_dict["token_count"] = 0
216+
219217
chat_completion_output_message_dict.update(llm_metadata)
220218

221219
transaction.record_custom_event("LlmChatCompletionMessage", chat_completion_output_message_dict)
@@ -286,15 +284,18 @@ def _record_embedding_success(transaction, embedding_id, linking_metadata, kwarg
286284
else getattr(attribute_response, "organization", None)
287285
)
288286

287+
response_total_tokens = attribute_response.get("usage", {}).get("total_tokens") if response else None
288+
289+
total_tokens = (
290+
settings.ai_monitoring.llm_token_count_callback(response_model, input_)
291+
if settings.ai_monitoring.llm_token_count_callback and input_
292+
else response_total_tokens
293+
)
294+
289295
full_embedding_response_dict = {
290296
"id": embedding_id,
291297
"span_id": span_id,
292298
"trace_id": trace_id,
293-
"token_count": (
294-
settings.ai_monitoring.llm_token_count_callback(response_model, input_)
295-
if settings.ai_monitoring.llm_token_count_callback
296-
else None
297-
),
298299
"request.model": kwargs.get("model") or kwargs.get("engine"),
299300
"request_id": request_id,
300301
"duration": ft.duration * 1000,
@@ -319,6 +320,7 @@ def _record_embedding_success(transaction, embedding_id, linking_metadata, kwarg
319320
"response.headers.ratelimitRemainingRequests": check_rate_limit_header(
320321
response_headers, "x-ratelimit-remaining-requests", True
321322
),
323+
"response.usage.total_tokens": total_tokens,
322324
"vendor": "openai",
323325
"ingest_source": "Python",
324326
}
@@ -489,13 +491,15 @@ def _handle_completion_success(
489491
def _record_completion_success(
490492
transaction, linking_metadata, completion_id, kwargs, ft, response_headers, response, request_timestamp=None
491493
):
494+
settings = transaction.settings if transaction.settings is not None else global_settings()
492495
span_id = linking_metadata.get("span.id")
493496
trace_id = linking_metadata.get("trace.id")
494497

495498
try:
496499
if response:
497500
response_model = response.get("model")
498501
response_id = response.get("id")
502+
token_usage = response.get("usage") or {}
499503
output_message_list = []
500504
finish_reason = None
501505
choices = response.get("choices") or []
@@ -509,6 +513,7 @@ def _record_completion_success(
509513
else:
510514
response_model = kwargs.get("response.model")
511515
response_id = kwargs.get("id")
516+
token_usage = kwargs.get("response.usage") or {}
512517
finish_reason = kwargs.get("finish_reason")
513518
content = kwargs.get("content")
514519
# Tool-call responses may carry an empty content string; in that case the
@@ -519,12 +524,39 @@ def _record_completion_success(
519524
output_message_list = [{"content": content, "role": kwargs.get("role")}]
520525
else:
521526
output_message_list = []
527+
522528
request_model = kwargs.get("model") or kwargs.get("engine")
523529

524-
request_id = response_headers.get("x-request-id")
525-
organization = response_headers.get("openai-organization") or getattr(response, "organization", None)
526530
messages = kwargs.get("messages") or [{"content": kwargs.get("prompt"), "role": "user"}]
527531
input_message_list = list(messages)
532+
533+
# Token counts default to those reported in the response object if available,
534+
# but the user registered callback below may override them.
535+
response_prompt_tokens = token_usage.get("prompt_tokens")
536+
response_completion_tokens = token_usage.get("completion_tokens")
537+
response_total_tokens = token_usage.get("total_tokens")
538+
539+
# If the user has registered a callback to compute token counts it should always be preferred.
540+
token_count_callback = settings.ai_monitoring.llm_token_count_callback
541+
if token_count_callback:
542+
input_message_content = " ".join(content for msg in input_message_list if (content := msg.get("content")))
543+
if input_message_content:
544+
response_prompt_tokens = token_count_callback(request_model, input_message_content)
545+
output_message_content = " ".join(content for msg in output_message_list if (content := msg.get("content")))
546+
if output_message_content:
547+
response_completion_tokens = token_count_callback(response_model, output_message_content)
548+
549+
# Prefer the sum of individual counts as the total whenever both are available.
550+
# This ensures consistency in the event that the token counting callback has reported
551+
# different values for prompt or completion tokens.
552+
if response_prompt_tokens and response_completion_tokens:
553+
response_total_tokens = response_prompt_tokens + response_completion_tokens
554+
555+
all_token_counts = bool(response_prompt_tokens and response_completion_tokens and response_total_tokens)
556+
557+
request_id = response_headers.get("x-request-id")
558+
organization = response_headers.get("openai-organization") or getattr(response, "organization", None)
559+
528560
full_chat_completion_summary_dict = {
529561
"id": completion_id,
530562
"span_id": span_id,
@@ -571,6 +603,12 @@ def _record_completion_success(
571603
"response.number_of_messages": len(input_message_list) + len(output_message_list),
572604
"timestamp": request_timestamp,
573605
}
606+
607+
if all_token_counts:
608+
full_chat_completion_summary_dict["response.usage.prompt_tokens"] = response_prompt_tokens
609+
full_chat_completion_summary_dict["response.usage.completion_tokens"] = response_completion_tokens
610+
full_chat_completion_summary_dict["response.usage.total_tokens"] = response_total_tokens
611+
574612
llm_metadata = _get_llm_attributes(transaction)
575613

576614
if "time_to_first_token" in kwargs:
@@ -586,11 +624,11 @@ def _record_completion_success(
586624
span_id,
587625
trace_id,
588626
response_model,
589-
request_model,
590627
response_id,
591628
request_id,
592629
llm_metadata,
593630
output_message_list,
631+
all_token_counts,
594632
request_timestamp,
595633
)
596634
except Exception:
@@ -602,6 +640,7 @@ def _record_completion_error(transaction, linking_metadata, completion_id, kwarg
602640
trace_id = linking_metadata.get("trace.id")
603641
request_message_list = kwargs.get("messages", None) or []
604642
notice_error_attributes = {}
643+
605644
try:
606645
if OPENAI_V1:
607646
response = getattr(exc, "response", None)
@@ -667,18 +706,20 @@ def _record_completion_error(transaction, linking_metadata, completion_id, kwarg
667706
output_message_list = []
668707
if "content" in kwargs:
669708
output_message_list = [{"content": kwargs.get("content"), "role": kwargs.get("role")}]
709+
670710
create_chat_completion_message_event(
671711
transaction,
672712
request_message_list,
673713
completion_id,
674714
span_id,
675715
trace_id,
676716
kwargs.get("response.model"),
677-
request_model,
678717
response_id,
679718
request_id,
680719
llm_metadata,
681720
output_message_list,
721+
# We do not record token counts in error cases, so set all_token_counts to True so the pipeline tokenizer does not run
722+
True,
682723
request_timestamp,
683724
)
684725
except Exception:
@@ -787,6 +828,7 @@ def _record_stream_chunk(self, return_val):
787828
self._nr_openai_attrs["response.model"] = return_val.get("model")
788829
self._nr_openai_attrs["id"] = return_val.get("id")
789830
self._nr_openai_attrs["response.organization"] = return_val.get("organization")
831+
self._nr_openai_attrs["response.usage"] = return_val.get("usage")
790832
if choices:
791833
delta = choices[0].get("delta") or {}
792834
if delta:

0 commit comments

Comments
 (0)