Skip to content

Commit 755bf8e

Browse files
Enhance token usage tracking and emission in Azure OpenAI clients and orchestrator
- Updated `_try_emit_token_event` to emit token usage to App Insights. - Integrated token usage tracking in non-streaming and streaming responses. - Registered active token usage tracker in `GroupChatOrchestrator` for accurate context. - Added context management for active tracker and agent/step labels in `token_usage_tracker`.
1 parent 38825e8 commit 755bf8e

3 files changed

Lines changed: 187 additions & 126 deletions

File tree

src/processor/src/libs/agent_framework/azure_openai_response_retry.py

Lines changed: 63 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,44 @@ def _extract_tokens_from_dict_or_obj(ud: Any) -> tuple[int, int, int]:
4242

4343

4444
def _try_emit_token_event(inp: int, out: int, tot: int, source: str) -> None:
45-
"""Log token usage found in stream/response for diagnostics.
46-
47-
The actual LLM_Token_Usage event is emitted by TokenUsageTracker.record()
48-
in the orchestrator, which has full context (agent, step, model, user).
49-
This function only logs for debugging to avoid duplicate events.
45+
"""Emit token usage found in a stream/response to App Insights.
46+
47+
The chat client retry wrapper is the only layer that reliably sees raw LLM
48+
``usage_details``; the orchestrator's streaming events and final conversation
49+
do not surface usage Content. We therefore record here, against the active
50+
``TokenUsageTracker`` (resolved from context), which both aggregates the
51+
usage and emits the per-call ``LLM_*`` Application Insights events. When no
52+
tracker is active in the current context this falls back to a diagnostic log.
5053
"""
51-
if tot > 0 or inp > 0 or out > 0:
52-
logger.info(
53-
"[TOKEN_STREAM] usage found: input=%s output=%s total=%s source=%s",
54-
inp, out, tot, source,
54+
if not (tot > 0 or inp > 0 or out > 0):
55+
return
56+
57+
logger.info(
58+
"[TOKEN_STREAM] usage found: input=%s output=%s total=%s source=%s",
59+
inp, out, tot, source,
60+
)
61+
try:
62+
from utils.token_usage_tracker import record_usage_from_context
63+
64+
emitted = record_usage_from_context(
65+
input_tokens=int(inp),
66+
output_tokens=int(out),
67+
total_tokens=int(tot),
68+
)
69+
if emitted:
70+
logger.info(
71+
"[TOKEN_STREAM] emitted LLM token event: total=%s source=%s",
72+
tot, source,
73+
)
74+
else:
75+
logger.warning(
76+
"[TOKEN_STREAM] no active TokenUsageTracker in context; "
77+
"token usage NOT emitted (total=%s source=%s)",
78+
tot, source,
79+
)
80+
except Exception as e:
81+
logger.warning(
82+
"[TOKEN_STREAM] failed to emit token usage (source=%s): %s", source, e
5583
)
5684

5785

@@ -836,8 +864,8 @@ async def _non_streaming_with_retry(
836864

837865
try:
838866
response = await _retry_call(
839-
lambda: parent_inner_get_response(
840-
messages=effective_messages, chat_options=chat_options, **kwargs
867+
lambda: parent_inner(
868+
messages=effective_messages, options=options, stream=False, **kwargs
841869
),
842870
config=self._retry_config,
843871
)
@@ -891,12 +919,15 @@ async def _non_streaming_with_retry(
891919
"[AOAI_CTX_TRIM] sleeping %ss before retry", round(trim_delay, 1)
892920
)
893921
await asyncio.sleep(trim_delay)
894-
return await _retry_call(
922+
response = await _retry_call(
895923
lambda: parent_inner(
896924
messages=trimmed, options=options, stream=False, **kwargs
897925
),
898926
config=self._retry_config,
899927
)
928+
# Emit token usage from the retried (context-trimmed) response too.
929+
_emit_usage_from_response(response)
930+
return response
900931

901932

902933
class AzureOpenAIChatClientWithRetry(OpenAIChatCompletionClient):
@@ -1040,123 +1071,29 @@ def _maybe_trim_messages(
10401071
return trimmed
10411072
return messages
10421073

1043-
iterator = stream.__aiter__()
1044-
try:
1045-
first = await iterator.__anext__()
1046-
1047-
async def _tail():
1048-
yield first
1049-
async for item in iterator:
1050-
yield item
1051-
1052-
_item_count = 0
1053-
_last_item = None
1054-
async for item in _tail():
1055-
_item_count += 1
1056-
_last_item = item
1057-
_emit_usage_from_stream_item(item)
1058-
yield item
1059-
1060-
# After stream completes, log diagnostic about the last item
1061-
if _last_item is not None:
1062-
try:
1063-
_attrs = [a for a in dir(_last_item) if not a.startswith("_")]
1064-
_contents = getattr(_last_item, "contents", None)
1065-
_content_info = []
1066-
if _contents:
1067-
for _c in _contents:
1068-
_ct = getattr(_c, "type", "?")
1069-
_ca = [a for a in dir(_c) if not a.startswith("_")]
1070-
_content_info.append({"type": _ct, "attrs": _ca})
1071-
_usage_attr = getattr(_last_item, "usage", None)
1072-
logger.info(
1073-
"[TOKEN_DIAG_FINAL] stream_items=%d last_item_type=%s attrs=%s contents=%s usage_attr=%s",
1074-
_item_count,
1075-
type(_last_item).__name__,
1076-
_attrs,
1077-
_content_info,
1078-
repr(_usage_attr) if _usage_attr is not None else "None",
1079-
)
1080-
except Exception:
1081-
pass
1082-
return
1083-
except StopAsyncIteration:
1084-
return
1085-
except Exception as e:
1086-
close = getattr(stream, "aclose", None)
1087-
if callable(close):
1088-
try:
1089-
await close()
1090-
except Exception:
1091-
logger.debug("Best-effort close of response stream failed", exc_info=True)
1092-
1093-
# Progressive retry for context-length failures.
1094-
if (
1095-
self._context_trim_config.enabled
1096-
and self._context_trim_config.retry_on_context_error
1097-
and _looks_like_context_length(e)
1098-
):
1099-
# Make trimming progressively more aggressive on each retry
1100-
# GPT-5.1: 272K input tokens ≈ 800K chars. Scale down from 600K default.
1101-
scale = attempt_index + 1
1102-
aggressive_cfg = ContextTrimConfig(
1103-
enabled=True,
1104-
max_total_chars=max(
1105-
30_000,
1106-
self._context_trim_config.max_total_chars - scale * 100_000,
1107-
),
1108-
max_message_chars=max(
1109-
2_000,
1110-
self._context_trim_config.max_message_chars - scale * 8_000,
1111-
),
1112-
keep_last_messages=max(
1113-
4,
1114-
self._context_trim_config.keep_last_messages - scale * 8,
1115-
),
1116-
keep_head_chars=max(
1117-
500,
1118-
self._context_trim_config.keep_head_chars - scale * 3_000,
1119-
),
1120-
keep_tail_chars=max(
1121-
500,
1122-
self._context_trim_config.keep_tail_chars - scale * 1_000,
1123-
),
1124-
keep_system_messages=True,
1125-
retry_on_context_error=True,
1126-
)
1127-
trimmed = _trim_messages(effective_messages, cfg=aggressive_cfg)
1128-
logger.warning(
1129-
"[AOAI_CTX_TRIM_STREAM] retrying after context-length error (attempt %s); count=%s -> %s, budget=%s",
1130-
attempt_index + 1,
1131-
len(effective_messages),
1132-
len(trimmed),
1133-
aggressive_cfg.max_total_chars,
1134-
)
1135-
effective_messages = trimmed
1136-
if attempt_index >= attempts - 1:
1137-
# No more retries available.
1138-
raise
1139-
1140-
# Cool down before retrying — immediate retries after trimming
1141-
# tend to trigger 429s because the API hasn't recovered yet.
1142-
trim_delay = self._retry_config.base_delay_seconds * (
1143-
2**attempt_index
1144-
)
1145-
trim_delay = min(trim_delay, self._retry_config.max_delay_seconds)
1146-
logger.info(
1147-
"[AOAI_CTX_TRIM_STREAM] sleeping %ss before retry",
1148-
round(trim_delay, 1),
1149-
)
1150-
await asyncio.sleep(trim_delay)
1151-
continue
1074+
async def _non_streaming_with_retry(
1075+
self,
1076+
*,
1077+
effective_messages: MutableSequence[Any] | list[Any],
1078+
original_messages: MutableSequence[Any],
1079+
options: Any = None,
1080+
**kwargs: Any,
1081+
) -> Any:
1082+
"""Non-streaming path: full retry + context-trim fallback."""
1083+
parent_inner = super(
1084+
AzureOpenAIChatClientWithRetry, self
1085+
)._inner_get_response
11521086

11531087
try:
1154-
return await _retry_call(
1088+
response = await _retry_call(
11551089
lambda: parent_inner(
11561090
messages=effective_messages, options=options, stream=False, **kwargs
11571091
),
11581092
config=self._retry_config,
11591093
)
1094+
# Extract and emit token usage from the non-streaming response.
1095+
_emit_usage_from_response(response)
1096+
return response
11601097
except Exception as e:
11611098
if not (
11621099
self._context_trim_config.enabled
@@ -1206,9 +1143,12 @@ async def _tail():
12061143
"[AOAI_CTX_TRIM] sleeping %ss before retry", round(trim_delay, 1)
12071144
)
12081145
await asyncio.sleep(trim_delay)
1209-
return await _retry_call(
1146+
response = await _retry_call(
12101147
lambda: parent_inner(
12111148
messages=trimmed, options=options, stream=False, **kwargs
12121149
),
12131150
config=self._retry_config,
12141151
)
1152+
# Emit token usage from the retried (context-trimmed) response too.
1153+
_emit_usage_from_response(response)
1154+
return response

src/processor/src/libs/agent_framework/groupchat_orchestrator.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@
3535
from mem0 import AsyncMemory
3636
from pydantic import AliasChoices, BaseModel, Field, ValidationError
3737

38-
from utils.token_usage_tracker import TokenUsageTracker, extract_usage_from_response, _parse_usage_object
38+
from utils.token_usage_tracker import (
39+
TokenUsageTracker,
40+
extract_usage_from_response,
41+
_parse_usage_object,
42+
set_active_tracker,
43+
set_token_context,
44+
)
3945

4046
logger = logging.getLogger(__name__)
4147

@@ -522,7 +528,14 @@ async def run_stream(
522528
self._tool_call_recorded.clear()
523529
self._tool_call_index.clear()
524530
self._streaming_captured_usage = False
525-
self._conversation: list[ChatMessage] = [] # Track conversation during workflow
531+
self._conversation: list[Message] = [] # Track conversation during workflow
532+
533+
# Register this orchestrator's tracker as the active one so the chat
534+
# client retry wrapper can emit token usage from the raw LLM responses
535+
# (the streaming events / final conversation never surface usage Content).
536+
if self.token_usage_tracker is not None:
537+
set_active_tracker(self.token_usage_tracker)
538+
set_token_context(step_name=self.name)
526539

527540
try:
528541
# Ensure initialized
@@ -809,6 +822,10 @@ async def _handle_agent_update(
809822
# twice on parse failure), the executor_id is identical but
810823
# response_id differs per invocation.
811824
response_id = getattr(event, "response_id", None)
825+
# Update the active agent label so token usage recorded by the chat
826+
# client retry wrapper is attributed to the right agent.
827+
if agent_name:
828+
set_token_context(agent_name=agent_name)
812829
await self._start_agent_if_needed(
813830
agent_name, stream_callback, callback, response_id=response_id
814831
)
@@ -1160,7 +1177,7 @@ def _backfill_tool_usage_from_conversation(
11601177
continue
11611178

11621179
def _backfill_token_usage_from_conversation(
1163-
self, conversation: list[ChatMessage]
1180+
self, conversation: list[Message]
11641181
) -> None:
11651182
"""Extract token usage from the final conversation messages.
11661183

0 commit comments

Comments
 (0)