From b85e0259e9193c5b7ab0f1f2295431b4a04d92ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=81=E5=B1=BF?= Date: Fri, 8 May 2026 18:28:03 +0800 Subject: [PATCH] Fix DashScope message output extraction --- .pre-commit-config.yaml | 3 +- .../CHANGELOG.md | 5 + .../pyproject.toml | 2 +- .../dashscope/utils/generation.py | 171 +++++++++--------- ...lt_without_tool_calls_content_capture.yaml | 93 ++++++++++ .../tests/test_generation.py | 66 +++++++ 6 files changed, 254 insertions(+), 86 deletions(-) create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/cassettes/test_generation_call_message_result_without_tool_calls_content_capture.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bfd06b878..2ff10bc32 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,4 +19,5 @@ repos: hooks: - id: rstcheck additional_dependencies: ['rstcheck[sphinx]'] - args: ["--report-level", "warning"] \ No newline at end of file + args: ["--report-level", "warning"] + exclude: "^_template/" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/CHANGELOG.md b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/CHANGELOG.md index a3fd394f0..0d5b409d8 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/CHANGELOG.md +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- Fix extraction of `gen_ai.output.messages` for message-format text responses + that omit optional `tool_calls`. + ## Version 0.4.0 (2026-04-03) There are no changelog entries for this release. diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml index 0f2e1f51e..5351cf714 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "opentelemetry-api ~= 1.37", "opentelemetry-instrumentation >= 0.58b0", "opentelemetry-semantic-conventions >= 0.58b0", - "opentelemetry-util-genai > 0.2b0", + "opentelemetry-util-genai", "wrapt >= 1.0.0, < 2.0.0", ] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py index 271f56fdd..c278842c4 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py @@ -35,6 +35,33 @@ logger = logging.getLogger(__name__) +_MISSING = object() + + +def _safe_get(obj: Any, key: str, default: Any = None) -> Any: + """Read a field from dict-like DashScope objects without leaking KeyError.""" + if obj is None: + return default + + if isinstance(obj, dict): + return obj.get(key, default) + + try: + get = getattr(obj, "get", None) + except (AttributeError, KeyError): + get = None + + if callable(get): + try: + return get(key, default) + except (AttributeError, KeyError, TypeError): + pass + + try: + return getattr(obj, key) + except (AttributeError, KeyError): + return default + def _extract_input_messages(kwargs: dict) -> List[InputMessage]: """Extract input messages from DashScope API kwargs. @@ -127,12 +154,12 @@ def _extract_input_messages(kwargs: dict) -> List[InputMessage]: parts=[Text(content=str(content), type="text")], ) ) - elif hasattr(msg, "role"): + elif _safe_get(msg, "role", _MISSING) is not _MISSING: # Handle message objects - role = getattr(msg, "role", "user") - content = getattr(msg, "content", "") - tool_call_id = getattr(msg, "tool_call_id", None) - tool_calls = getattr(msg, "tool_calls", None) + role = _safe_get(msg, "role", "user") + content = _safe_get(msg, "content", "") + tool_call_id = _safe_get(msg, "tool_call_id") + tool_calls = _safe_get(msg, "tool_calls") parts = [] @@ -166,24 +193,23 @@ def _extract_input_messages(kwargs: dict) -> List[InputMessage]: # Add tool calls if present if tool_calls: for tool_call in tool_calls: - if hasattr(tool_call, "function"): - function = getattr(tool_call, "function", None) - if function: - tool_name = getattr(function, "name", None) - tool_args = getattr( - function, "arguments", None - ) - tc_id = getattr(tool_call, "id", None) + function = _safe_get(tool_call, "function", _MISSING) + if function is _MISSING: + continue + if function: + tool_name = _safe_get(function, "name") + tool_args = _safe_get(function, "arguments") + tc_id = _safe_get(tool_call, "id") - if tool_name: - parts.append( - ToolCall( - name=tool_name, - arguments=tool_args, - id=tc_id, - type="tool_call", - ) + if tool_name: + parts.append( + ToolCall( + name=tool_name, + arguments=tool_args, + id=tc_id, + type="tool_call", ) + ) if parts: input_messages.append( @@ -306,14 +332,12 @@ def _extract_output_messages(response: Any) -> List[OutputMessage]: return output_messages try: - # Use getattr with default None to safely access attributes - # DashScope response uses __getattr__ which raises KeyError for missing attributes - output = getattr(response, "output", None) + output = _safe_get(response, "output") if not output: return output_messages # Check for choices format (qwen-vl and some models) - choices = getattr(output, "choices", None) + choices = _safe_get(output, "choices") if choices and isinstance(choices, list) and len(choices) > 0: # Process each choice for choice in choices: @@ -321,16 +345,16 @@ def _extract_output_messages(response: Any) -> List[OutputMessage]: continue # Extract message from choice - message = getattr(choice, "message", None) + message = _safe_get(choice, "message") if not message: continue # Extract content and tool_calls - content = getattr(message, "content", None) - tool_calls = getattr(message, "tool_calls", None) - finish_reason = getattr( - choice, "finish_reason", None - ) or getattr(output, "finish_reason", "stop") + content = _safe_get(message, "content") + tool_calls = _safe_get(message, "tool_calls") + finish_reason = _safe_get( + choice, "finish_reason" + ) or _safe_get(output, "finish_reason", "stop") parts = [] @@ -375,25 +399,26 @@ def _extract_output_messages(response: Any) -> List[OutputMessage]: type="tool_call", ) ) - elif hasattr(tool_call, "function"): - # Handle tool call objects - function = getattr(tool_call, "function", None) - if function: - tool_name = getattr(function, "name", None) - tool_args = getattr( - function, "arguments", None - ) - tool_call_id = getattr(tool_call, "id", None) + continue - if tool_name: - parts.append( - ToolCall( - name=tool_name, - arguments=tool_args, - id=tool_call_id, - type="tool_call", - ) + # Handle tool call objects + function = _safe_get(tool_call, "function", _MISSING) + if function is _MISSING: + continue + if function: + tool_name = _safe_get(function, "name") + tool_args = _safe_get(function, "arguments") + tool_call_id = _safe_get(tool_call, "id") + + if tool_name: + parts.append( + ToolCall( + name=tool_name, + arguments=tool_args, + id=tool_call_id, + type="tool_call", ) + ) # Create output message if we have parts OR if finish_reason indicates tool_calls # (even if content is empty, tool calls should be captured) @@ -407,10 +432,8 @@ def _extract_output_messages(response: Any) -> List[OutputMessage]: ) else: # Standard format: output.text - text = getattr(output, "text", None) or getattr( - output, "content", None - ) - finish_reason = getattr(output, "finish_reason", "stop") + text = _safe_get(output, "text") or _safe_get(output, "content") + finish_reason = _safe_get(output, "finish_reason", "stop") if text: output_messages.append( @@ -546,27 +569,14 @@ def _update_invocation_from_response( invocation.output_tokens = output_tokens # Extract response model name (if available) - # Use try-except to safely access attributes - # DashScope response uses __getattr__ which raises KeyError for missing attributes - try: - response_model = getattr(response, "model", None) - if response_model: - invocation.response_model_name = response_model - except (KeyError, AttributeError) as e: - logger.debug( - "Failed to extract response model from Generation response: %s", - e, - ) + response_model = _safe_get(response, "model") + if response_model: + invocation.response_model_name = response_model # Extract request ID (if available) - try: - request_id = getattr(response, "request_id", None) - if request_id: - invocation.response_id = request_id - except (KeyError, AttributeError) as e: - logger.debug( - "Failed to extract request_id from Generation response: %s", e - ) + request_id = _safe_get(response, "request_id") + if request_id: + invocation.response_id = request_id except (KeyError, AttributeError) as e: # If any attribute access fails, silently continue with available data logger.debug( @@ -586,8 +596,8 @@ def _create_accumulated_response(original_response, accumulated_text): A response object with accumulated text, or original_response if modification fails """ try: - output = getattr(original_response, "output", None) - if output and hasattr(output, "text"): + output = _safe_get(original_response, "output") + if output and _safe_get(output, "text") is not None: # Try to set the accumulated text directly try: output.text = accumulated_text @@ -603,7 +613,7 @@ def _create_accumulated_response(original_response, accumulated_text): class AccumulatedOutput: def __init__(self, original_output, accumulated_text): self.text = accumulated_text - self.finish_reason = getattr( + self.finish_reason = _safe_get( original_output, "finish_reason", "stop" ) self.content = accumulated_text @@ -613,16 +623,9 @@ def __init__(self, original_response, accumulated_output): self.output = accumulated_output # Copy other attributes from original response for attr in ["usage", "request_id", "model"]: - try: - value = getattr(original_response, attr, None) - if value is not None: - setattr(self, attr, value) - except (KeyError, AttributeError) as e: - logger.debug( - "Failed to set attribute %s on accumulated response: %s", - attr, - e, - ) + value = _safe_get(original_response, attr) + if value is not None: + setattr(self, attr, value) accumulated_output = AccumulatedOutput(output, accumulated_text) return AccumulatedResponse(original_response, accumulated_output) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/cassettes/test_generation_call_message_result_without_tool_calls_content_capture.yaml b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/cassettes/test_generation_call_message_result_without_tool_calls_content_capture.yaml new file mode 100644 index 000000000..039311dfe --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/cassettes/test_generation_call_message_result_without_tool_calls_content_capture.yaml @@ -0,0 +1,93 @@ +interactions: +- request: + body: |- + { + "model": "qwen-turbo", + "parameters": { + "result_format": "message" + }, + "input": { + "messages": [ + { + "role": "user", + "content": "Reply with exactly: dashscope plain message" + } + ] + } + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '168' + Content-Type: + - application/json + authorization: + - Bearer test_dashscope_api_key + user-agent: + - dashscope/1.23.6; python/3.13.12; platform/macOS-15.1.1-arm64-arm-64bit-Mach-O; + processor/arm + method: POST + uri: https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation + response: + body: + string: |- + { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "content": "dashscope plain message", + "role": "assistant" + } + } + ] + }, + "usage": { + "input_tokens": 20, + "output_tokens": 4, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 24 + }, + "request_id": "19291c35-da65-9ab4-8cc7-abd7aefffa0a" + } + headers: + content-length: + - '276' + content-type: + - application/json + date: + - Fri, 08 May 2026 10:21:25 GMT + req-arrive-time: + - '1778235685437' + req-cost-time: + - '191' + resp-start-time: + - '1778235685629' + server: + - istio-envoy + transfer-encoding: + - chunked + vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + x-dashscope-call-gateway: + - 'true' + x-dashscope-finished: + - 'true' + x-dashscope-timeout: + - '298' + x-envoy-upstream-service-time: + - '184' + x-request-id: + - 19291c35-da65-9ab4-8cc7-abd7aefffa0a + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_generation.py b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_generation.py index 38c8ee1e1..48eb1ad9f 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_generation.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_generation.py @@ -655,6 +655,72 @@ def test_generation_call_with_content_capture( print("✓ Generation.call (with content capture) completed successfully") +@pytest.mark.vcr() +def test_generation_call_message_result_without_tool_calls_content_capture( + instrument_with_content, span_exporter +): + """Test message-format text responses that omit the tool_calls field.""" + + messages = [ + { + "role": "user", + "content": "Reply with exactly: dashscope plain message", + } + ] + response = Generation.call( + model="qwen-turbo", + messages=messages, + result_format="message", + ) + + assert response is not None + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1, f"Expected 1 span, got {len(spans)}" + + span = spans[0] + output = _safe_getattr(response, "output", None) + choices = _safe_getattr(output, "choices", None) if output else None + finish_reason = None + if choices: + finish_reason = _safe_getattr(choices[0], "finish_reason", None) + if not finish_reason and output: + finish_reason = _safe_getattr(output, "finish_reason", None) + + usage = _safe_getattr(response, "usage", None) + _assert_generation_span_attributes( + span, + request_model="qwen-turbo", + response_id=_safe_getattr(response, "request_id", None), + response_model=_safe_getattr(response, "model", None), + input_tokens=_safe_getattr(usage, "input_tokens", None) + if usage + else None, + output_tokens=_safe_getattr(usage, "output_tokens", None) + if usage + else None, + finish_reasons=[finish_reason] if finish_reason else None, + expect_input_messages=True, + expect_output_messages=True, + ) + + output_messages = span.attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] + if isinstance(output_messages, str): + output_messages = json_utils.loads(output_messages) + + assert len(output_messages) == 1 + assert output_messages[0]["role"] == "assistant" + assert output_messages[0]["finish_reason"] == "stop" + assert output_messages[0]["parts"][0]["type"] == "text" + assert ( + output_messages[0]["parts"][0]["content"] == "dashscope plain message" + ) + + print( + "✓ Generation.call (message result without tool_calls, content capture) completed successfully" + ) + + @pytest.mark.vcr() def test_generation_call_no_content_capture( instrument_no_content, span_exporter