@@ -102,6 +102,183 @@ def _extract_content_text(content: Any) -> str:
102102 return str (content ) if content else ""
103103
104104
105+ def _field_value (value : Any , * names : str ) -> Any :
106+ """Read the first present field from a mapping or SDK response object."""
107+ if value is None :
108+ return None
109+
110+ for name in names :
111+ if isinstance (value , dict ):
112+ if name in value :
113+ return value [name ]
114+ continue
115+
116+ try :
117+ attr_value = getattr (value , name )
118+ except Exception :
119+ attr_value = None
120+ if attr_value is not None :
121+ return attr_value
122+
123+ get_method = getattr (value , "get" , None )
124+ if callable (get_method ):
125+ try :
126+ got_value = get_method (name )
127+ except Exception :
128+ got_value = None
129+ if got_value is not None :
130+ return got_value
131+
132+ return None
133+
134+
135+ def _int_value (value : Any ) -> Optional [int ]:
136+ if value is None :
137+ return None
138+ try :
139+ return int (value )
140+ except (TypeError , ValueError ):
141+ return None
142+
143+
144+ def _usage_token_values (usage : Any ) -> Dict [str , int ]:
145+ if usage is None :
146+ return {}
147+
148+ input_tokens = _int_value (
149+ _field_value (usage , "input_tokens" , "prompt_tokens" )
150+ )
151+ output_tokens = _int_value (
152+ _field_value (usage , "output_tokens" , "completion_tokens" )
153+ )
154+ cache_read_tokens = _int_value (
155+ _field_value (usage , "cache_read_input_tokens" , "cached_prompt_tokens" )
156+ )
157+ cache_creation_tokens = _int_value (
158+ _field_value (usage , "cache_creation_input_tokens" )
159+ )
160+
161+ for detail_name in ("prompt_tokens_details" , "input_tokens_details" ):
162+ details = _field_value (usage , detail_name )
163+ if details is not None and cache_read_tokens is None :
164+ cache_read_tokens = _int_value (
165+ _field_value (details , "cached_tokens" )
166+ )
167+
168+ values : Dict [str , int ] = {}
169+ if input_tokens is not None :
170+ values ["input_tokens" ] = input_tokens
171+ if output_tokens is not None :
172+ values ["output_tokens" ] = output_tokens
173+ if cache_read_tokens is not None and cache_read_tokens > 0 :
174+ values ["cache_read_input_tokens" ] = cache_read_tokens
175+ if cache_creation_tokens is not None and cache_creation_tokens > 0 :
176+ values ["cache_creation_input_tokens" ] = cache_creation_tokens
177+
178+ return values
179+
180+
181+ def _usage_score (usage_values : Dict [str , int ]) -> int :
182+ return (usage_values .get ("input_tokens" ) or 0 ) + (
183+ usage_values .get ("output_tokens" ) or 0
184+ )
185+
186+
187+ def _usage_sources (value : Any ) -> List [Any ]:
188+ sources = []
189+ usage = _field_value (value , "usage" )
190+ if usage is not None :
191+ sources .append (usage )
192+
193+ extra = _field_value (value , "extra" )
194+ if extra is not None :
195+ extra_usage = _field_value (extra , "usage" , "usage_metadata" )
196+ if extra_usage is not None :
197+ sources .append (extra_usage )
198+
199+ service_info = _field_value (extra , "model_service_info" )
200+ if service_info is not None :
201+ sources .append (service_info )
202+
203+ service_info = _field_value (value , "model_service_info" )
204+ if service_info is not None :
205+ sources .append (service_info )
206+
207+ return sources
208+
209+
210+ def _extract_usage_values (value : Any , depth : int = 0 ) -> Dict [str , int ]:
211+ """Extract token usage from qwen-agent Message/extra/model_service_info."""
212+ if value is None or depth > 4 :
213+ return {}
214+
215+ best_values : Dict [str , int ] = {}
216+ values = _usage_token_values (value )
217+ if values :
218+ best_values = values
219+
220+ if isinstance (value , (list , tuple )):
221+ for item in reversed (value ):
222+ item_values = _extract_usage_values (item , depth + 1 )
223+ if _usage_score (item_values ) > _usage_score (best_values ):
224+ best_values = item_values
225+ return best_values
226+
227+ for source in _usage_sources (value ):
228+ source_values = _extract_usage_values (source , depth + 1 )
229+ if _usage_score (source_values ) > _usage_score (best_values ):
230+ best_values = source_values
231+
232+ return best_values
233+
234+
235+ def _apply_usage_to_llm_invocation (
236+ invocation : LLMInvocation , value : Any
237+ ) -> None :
238+ """Apply qwen-agent token usage metadata to an LLMInvocation.
239+
240+ Qwen-Agent stores DashScope responses under Message.extra["model_service_info"]
241+ for both streaming and non-streaming calls. Streaming chunks can carry
242+ cumulative usage, so only replace existing values when the candidate usage
243+ has at least as many observed tokens as the current invocation.
244+ """
245+ usage_values = _extract_usage_values (value )
246+ if not usage_values :
247+ return
248+
249+ current_score = (invocation .input_tokens or 0 ) + (
250+ invocation .output_tokens or 0
251+ )
252+ if current_score and _usage_score (usage_values ) < current_score :
253+ return
254+
255+ if "input_tokens" in usage_values :
256+ invocation .input_tokens = usage_values ["input_tokens" ]
257+ if "output_tokens" in usage_values :
258+ invocation .output_tokens = usage_values ["output_tokens" ]
259+ if "cache_read_input_tokens" in usage_values :
260+ invocation .usage_cache_read_input_tokens = usage_values [
261+ "cache_read_input_tokens"
262+ ]
263+ if "cache_creation_input_tokens" in usage_values :
264+ invocation .usage_cache_creation_input_tokens = usage_values [
265+ "cache_creation_input_tokens"
266+ ]
267+
268+
269+ def apply_token_usage_from_qwen_messages (
270+ invocation : LLMInvocation ,
271+ messages : Any ,
272+ ) -> None :
273+ """Populate token usage from qwen-agent Message metadata.
274+
275+ Kept as a compatibility entrypoint for callers that used the previous
276+ helper name; the instrumentation wrapper now calls the generic extractor
277+ directly so it can process individual streaming chunks.
278+ """
279+ _apply_usage_to_llm_invocation (invocation , messages )
280+
281+
105282def _convert_qwen_messages_to_input_messages (
106283 messages : Any ,
107284) -> List [InputMessage ]:
@@ -291,6 +468,47 @@ def _convert_qwen_messages_to_output_messages(
291468 return output_messages
292469
293470
471+ def _convert_qwen_agent_final_output_messages (
472+ messages : Any ,
473+ ) -> List [OutputMessage ]:
474+ """Convert only the final qwen-agent answer to GenAI OutputMessage format."""
475+ if not messages :
476+ return []
477+
478+ if not isinstance (messages , list ):
479+ messages = [messages ]
480+
481+ for msg in reversed (messages ):
482+ try :
483+ role = (
484+ msg .role
485+ if hasattr (msg , "role" )
486+ else msg .get ("role" , "assistant" )
487+ )
488+ function_call = (
489+ msg .function_call
490+ if hasattr (msg , "function_call" )
491+ else msg .get ("function_call" )
492+ )
493+ content = (
494+ msg .content
495+ if hasattr (msg , "content" )
496+ else msg .get ("content" , "" )
497+ )
498+
499+ if role in ("function" , "tool" ) or function_call :
500+ continue
501+
502+ text = _extract_content_text (content )
503+ if text :
504+ return _convert_qwen_messages_to_output_messages ([msg ])
505+ except Exception as e :
506+ logger .debug (f"Error extracting final agent output message: { e } " )
507+ continue
508+
509+ return _convert_qwen_messages_to_output_messages (messages )
510+
511+
294512def _get_tool_definitions (
295513 functions : Optional [List [Dict ]],
296514) -> Optional [List [FunctionToolDefinition ]]:
0 commit comments