Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ repos:
hooks:
- id: rstcheck
additional_dependencies: ['rstcheck[sphinx]']
args: ["--report-level", "warning"]
args: ["--report-level", "warning"]
exclude: "^_template/"
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This package depends on the latest opentelemetry-util-genai implementation maintained in the LoongSuite repo, so we intentionally avoid pinning or version-constraining this dependency.

"wrapt >= 1.0.0, < 2.0.0",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -306,31 +332,29 @@ 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:
if not choice:
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 = []

Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading