Skip to content

Commit 9ecb205

Browse files
Merge pull request #294 from llPriyanka/psl-pri/conflict-resolve-token-usage
feat: generic token usage tracking implementation
2 parents 44f3e43 + 727bbb7 commit 9ecb205

19 files changed

Lines changed: 4160 additions & 865 deletions

File tree

src/backend-api/src/app/libs/logging/llm_token_telemetry.py

Lines changed: 1037 additions & 0 deletions
Large diffs are not rendered by default.

src/processor/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ dependencies = [
1313
"azure-ai-projects==2.1.0",
1414
"azure-appconfiguration==1.8.0",
1515
"azure-core==1.38.0",
16+
"azure-monitor-events-extension==0.1.0",
17+
"azure-monitor-opentelemetry==1.8.7",
1618
"azure-cosmos==4.15.0",
1719
"azure-identity==1.26.0b1",
1820
"azure-storage-blob==12.28.0",

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

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,140 @@
2323
logger = logging.getLogger(__name__)
2424

2525

26+
def _extract_tokens_from_dict_or_obj(ud: Any) -> tuple[int, int, int]:
27+
"""Extract (input, output, total) token counts from a dict or object."""
28+
inp = out = tot = 0
29+
if isinstance(ud, dict):
30+
inp = ud.get("input_token_count", 0) or ud.get("input_tokens", 0) or 0
31+
out = ud.get("output_token_count", 0) or ud.get("output_tokens", 0) or 0
32+
tot = ud.get("total_token_count", 0) or ud.get("total_tokens", 0) or 0
33+
else:
34+
inp = getattr(ud, "input_token_count", 0) or getattr(ud, "input_tokens", 0) or 0
35+
out = getattr(ud, "output_token_count", 0) or getattr(ud, "output_tokens", 0) or 0
36+
tot = getattr(ud, "total_token_count", 0) or getattr(ud, "total_tokens", 0) or 0
37+
if not tot:
38+
tot = int(inp) + int(out)
39+
return int(inp), int(out), int(tot)
40+
41+
42+
def _try_emit_token_event(inp: int, out: int, tot: int, source: str) -> None:
43+
"""Log token usage found in stream/response for diagnostics.
44+
45+
The actual LLM_Token_Usage event is emitted by TokenUsageTracker.record()
46+
in the orchestrator, which has full context (agent, step, model, user).
47+
This function only logs for debugging to avoid duplicate events.
48+
"""
49+
if tot > 0 or inp > 0 or out > 0:
50+
logger.info(
51+
"[TOKEN_STREAM] usage found: input=%s output=%s total=%s source=%s",
52+
inp, out, tot, source,
53+
)
54+
55+
56+
def _emit_usage_from_stream_item(item: Any) -> None:
57+
"""Check a streamed ChatResponseUpdate for usage Content and emit an App Insights event.
58+
59+
Checks multiple locations where usage data may appear:
60+
1. item.contents[] with type="usage" and usage_details
61+
2. item.usage (direct attribute - some SDK versions)
62+
3. item.metadata with usage keys
63+
"""
64+
try:
65+
item_type = type(item).__name__
66+
67+
# --- Path 1: contents list with Content(type="usage") ---
68+
contents = getattr(item, "contents", None)
69+
if contents:
70+
for content in contents:
71+
ctype = getattr(content, "type", None)
72+
if ctype == "usage":
73+
# SDK UsageContent uses "details"; fall back to "usage_details"
74+
ud = getattr(content, "details", None) or getattr(content, "usage_details", None)
75+
if ud:
76+
inp, out, tot = _extract_tokens_from_dict_or_obj(ud)
77+
_try_emit_token_event(inp, out, tot, "stream_contents")
78+
return
79+
80+
# --- Path 2: direct .usage attribute ---
81+
usage = getattr(item, "usage", None)
82+
if usage is not None:
83+
inp, out, tot = _extract_tokens_from_dict_or_obj(usage)
84+
_try_emit_token_event(inp, out, tot, "stream_usage_attr")
85+
return
86+
87+
# --- Path 3: .metadata dict with usage keys ---
88+
metadata = getattr(item, "metadata", None)
89+
if isinstance(metadata, dict):
90+
if any(k in metadata for k in ("input_tokens", "input_token_count", "usage")):
91+
usage_data = metadata.get("usage", metadata)
92+
inp, out, tot = _extract_tokens_from_dict_or_obj(usage_data)
93+
_try_emit_token_event(inp, out, tot, "stream_metadata")
94+
return
95+
96+
# --- Diagnostic: log item shape for debugging (only for non-text items) ---
97+
if contents:
98+
content_types = [getattr(c, "type", "?") for c in contents]
99+
if any(t not in ("text",) for t in content_types):
100+
logger.debug(
101+
"[TOKEN_DIAG] item_type=%s content_types=%s attrs=%s",
102+
item_type,
103+
content_types,
104+
[a for a in dir(item) if not a.startswith("_")],
105+
)
106+
except Exception as e:
107+
logger.debug("[TOKEN_STREAM] error in emit: %s", e)
108+
109+
110+
def _emit_usage_from_response(response: Any) -> None:
111+
"""Extract and emit token usage from a non-streaming ChatResponse.
112+
113+
Checks usage_details (SDK attribute) and contents for UsageContent items.
114+
"""
115+
try:
116+
# Path 1: response.usage_details (ChatResponse from SDK)
117+
ud = getattr(response, "usage_details", None) or getattr(response, "details", None)
118+
if ud is not None:
119+
inp, out, tot = _extract_tokens_from_dict_or_obj(ud)
120+
_try_emit_token_event(inp, out, tot, "response_usage_details")
121+
return
122+
123+
# Path 2: response.usage direct attribute
124+
usage = getattr(response, "usage", None)
125+
if usage is not None:
126+
inp, out, tot = _extract_tokens_from_dict_or_obj(usage)
127+
_try_emit_token_event(inp, out, tot, "response_usage_attr")
128+
return
129+
130+
# Path 3: contents list with UsageContent
131+
contents = getattr(response, "contents", None)
132+
if contents:
133+
for content in contents:
134+
ctype = getattr(content, "type", None)
135+
if ctype == "usage":
136+
ud = getattr(content, "details", None) or getattr(content, "usage_details", None)
137+
if ud:
138+
inp, out, tot = _extract_tokens_from_dict_or_obj(ud)
139+
_try_emit_token_event(inp, out, tot, "response_contents")
140+
return
141+
142+
# Path 4: messages list with usage content
143+
messages = getattr(response, "messages", None)
144+
if messages:
145+
for msg in messages:
146+
msg_contents = getattr(msg, "contents", None)
147+
if not msg_contents:
148+
continue
149+
for item in msg_contents:
150+
if getattr(item, "type", None) == "usage":
151+
ud = getattr(item, "details", None) or getattr(item, "usage_details", None)
152+
if ud:
153+
inp, out, tot = _extract_tokens_from_dict_or_obj(ud)
154+
_try_emit_token_event(inp, out, tot, "response_msg_contents")
155+
return
156+
except Exception as e:
157+
logger.debug("[TOKEN_RESPONSE] error in emit: %s", e)
158+
159+
26160
def _format_exc_brief(exc: BaseException) -> str:
27161
name = type(exc).__name__
28162
msg = str(exc)
@@ -80,9 +214,15 @@ def _looks_like_rate_limit(error: BaseException) -> bool:
80214

81215
# "The model produced invalid content" is a transient error from Azure OpenAI
82216
# when the model output fails content/schema validation — worth retrying.
217+
# "No tool call found" is a 400 error when the conversation has orphaned
218+
# function call outputs with no matching tool call request.
83219
if any(
84220
s in msg
85-
for s in ["model produced invalid content", "invalid content"]
221+
for s in [
222+
"model produced invalid content",
223+
"invalid content",
224+
"no tool call found",
225+
]
86226
):
87227
return True
88228

@@ -556,12 +696,15 @@ async def _inner_get_response(
556696
)
557697

558698
try:
559-
return await _retry_call(
699+
response = await _retry_call(
560700
lambda: parent_inner_get_response(
561701
messages=effective_messages, chat_options=chat_options, **kwargs
562702
),
563703
config=self._retry_config,
564704
)
705+
# Extract and emit token usage from non-streaming response
706+
_emit_usage_from_response(response)
707+
return response
565708
except Exception as e:
566709
if not (
567710
self._context_trim_config.enabled
@@ -651,8 +794,36 @@ async def _tail():
651794
async for item in iterator:
652795
yield item
653796

797+
_item_count = 0
798+
_last_item = None
654799
async for item in _tail():
800+
_item_count += 1
801+
_last_item = item
802+
_emit_usage_from_stream_item(item)
655803
yield item
804+
805+
# After stream completes, log diagnostic about the last item
806+
if _last_item is not None:
807+
try:
808+
_attrs = [a for a in dir(_last_item) if not a.startswith("_")]
809+
_contents = getattr(_last_item, "contents", None)
810+
_content_info = []
811+
if _contents:
812+
for _c in _contents:
813+
_ct = getattr(_c, "type", "?")
814+
_ca = [a for a in dir(_c) if not a.startswith("_")]
815+
_content_info.append({"type": _ct, "attrs": _ca})
816+
_usage_attr = getattr(_last_item, "usage", None)
817+
logger.info(
818+
"[TOKEN_DIAG_FINAL] stream_items=%d last_item_type=%s attrs=%s contents=%s usage_attr=%s",
819+
_item_count,
820+
type(_last_item).__name__,
821+
_attrs,
822+
_content_info,
823+
repr(_usage_attr) if _usage_attr is not None else "None",
824+
)
825+
except Exception:
826+
pass
656827
return
657828
except StopAsyncIteration:
658829
return

0 commit comments

Comments
 (0)