|
23 | 23 | logger = logging.getLogger(__name__) |
24 | 24 |
|
25 | 25 |
|
| 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 | + |
26 | 160 | def _format_exc_brief(exc: BaseException) -> str: |
27 | 161 | name = type(exc).__name__ |
28 | 162 | msg = str(exc) |
@@ -80,9 +214,15 @@ def _looks_like_rate_limit(error: BaseException) -> bool: |
80 | 214 |
|
81 | 215 | # "The model produced invalid content" is a transient error from Azure OpenAI |
82 | 216 | # 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. |
83 | 219 | if any( |
84 | 220 | 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 | + ] |
86 | 226 | ): |
87 | 227 | return True |
88 | 228 |
|
@@ -556,12 +696,15 @@ async def _inner_get_response( |
556 | 696 | ) |
557 | 697 |
|
558 | 698 | try: |
559 | | - return await _retry_call( |
| 699 | + response = await _retry_call( |
560 | 700 | lambda: parent_inner_get_response( |
561 | 701 | messages=effective_messages, chat_options=chat_options, **kwargs |
562 | 702 | ), |
563 | 703 | config=self._retry_config, |
564 | 704 | ) |
| 705 | + # Extract and emit token usage from non-streaming response |
| 706 | + _emit_usage_from_response(response) |
| 707 | + return response |
565 | 708 | except Exception as e: |
566 | 709 | if not ( |
567 | 710 | self._context_trim_config.enabled |
@@ -651,8 +794,36 @@ async def _tail(): |
651 | 794 | async for item in iterator: |
652 | 795 | yield item |
653 | 796 |
|
| 797 | + _item_count = 0 |
| 798 | + _last_item = None |
654 | 799 | async for item in _tail(): |
| 800 | + _item_count += 1 |
| 801 | + _last_item = item |
| 802 | + _emit_usage_from_stream_item(item) |
655 | 803 | 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 |
656 | 827 | return |
657 | 828 | except StopAsyncIteration: |
658 | 829 | return |
|
0 commit comments