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
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

### Changed

- Align streaming timing, reasoning-token usage, provider names, and agent
version attributes with the current upstream GenAI conventions.

### Fixed

- Start AgentScope v2 streaming LLM spans before invoking the underlying model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
import json
import logging
import timeit
from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence
from collections.abc import (
AsyncGenerator,
Awaitable,
Callable,
Mapping,
Sequence,
)
from contextvars import ContextVar
from dataclasses import asdict, is_dataclass
from typing import Any
Expand Down Expand Up @@ -179,6 +185,7 @@ async def on_model_call(
try:
result = await next_handler(**input_kwargs)
if inspect.isasyncgen(result):
invocation.stream = True
return self._wrap_model_stream(
result,
invocation,
Expand All @@ -201,14 +208,18 @@ async def _wrap_model_stream(
invocation: LLMInvocation,
handler: ExtendedTelemetryHandler,
) -> AsyncGenerator[ChatResponse, None]:
first_token_seen = False
last_chunk = None
closed = False
try:
async for chunk in result:
if not first_token_seen:
invocation.monotonic_first_token_s = timeit.default_timer()
first_token_seen = True
now = timeit.default_timer()
if invocation.monotonic_first_chunk_s is None:
invocation.monotonic_first_chunk_s = now
if (
invocation.monotonic_first_token_s is None
and _chat_response_has_token(chunk)
):
invocation.monotonic_first_token_s = now
last_chunk = chunk
yield chunk
except BaseException as exc:
Expand Down Expand Up @@ -324,6 +335,7 @@ def _create_llm_invocation(
provider=_get_provider_name(model),
input_messages=_messages_to_inputs(input_kwargs.get("messages")),
tool_definitions=_tool_definitions(input_kwargs.get("tools")),
stream=True if _is_streaming_model(model, input_kwargs) else None,
)
parameters = getattr(model, "parameters", None)
for source in (parameters, input_kwargs):
Expand Down Expand Up @@ -461,6 +473,30 @@ def _is_first_token_event(item: Any) -> bool:
return event_type in _FIRST_TOKEN_EVENT_TYPES


def _chat_response_has_token(response: Any) -> bool:
content = _safe_get(response, "content")
if isinstance(content, str):
return bool(content)
for block in content or []:
block_type = _safe_get(block, "type")
if block_type == "text" and _safe_get(block, "text"):
return True
if block_type == "thinking" and _safe_get(block, "thinking"):
return True
if block_type == "tool_call":
return True
return False


def _safe_get(value: Any, name: str) -> Any:
if isinstance(value, Mapping):
return value.get(name)
try:
return getattr(value, name, None)
except (AttributeError, KeyError):
return None


def _middleware_arg_position() -> int | None:
try:
parameters = list(inspect.signature(Agent.__init__).parameters)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
import threading
import timeit
import weakref
from collections.abc import Mapping
from dataclasses import dataclass, field
from functools import wraps
from typing import Any, AsyncGenerator, MutableMapping
Expand All @@ -80,6 +81,31 @@

logger = logging.getLogger(__name__)


def _safe_get(value: Any, name: str) -> Any:
if isinstance(value, Mapping):
return value.get(name)
try:
return getattr(value, name, None)
except (AttributeError, KeyError):
return None


def _stream_chunk_has_token(chunk: Any) -> bool:
content = _safe_get(chunk, "content")
if isinstance(content, str):
return bool(content)
for block in content or []:
block_type = _safe_get(block, "type")
if block_type == "text" and _safe_get(block, "text"):
return True
if block_type == "thinking" and _safe_get(block, "thinking"):
return True
if block_type == "tool_call":
return True
return bool(_safe_get(chunk, "tool_calls"))


# Fixed hook name shared by all concurrent invocations on a given agent.
# Using a stable name (rather than a per-call uuid) lets AgentScope's own
# registry naturally deduplicate, so a single set of callbacks fires for
Expand Down Expand Up @@ -392,12 +418,16 @@ async def _wrap_streaming_response(
"""Wrap streaming response to update invocation when done."""
try:
last_chunk = None
first_token_received = False
async for chunk in generator:
# Record time when first token is received
if not first_token_received:
first_token_received = True
invocation.monotonic_first_token_s = timeit.default_timer()
now = timeit.default_timer()
invocation.stream = True
if invocation.monotonic_first_chunk_s is None:
invocation.monotonic_first_chunk_s = now
if (
invocation.monotonic_first_token_s is None
and _stream_chunk_has_token(chunk)
):
invocation.monotonic_first_token_s = now

last_chunk = chunk
yield chunk
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ def get_message_converter(
elif provide_name == "ollama": # AgentScopeGenAiProviderName.OLLAMA.value
parser = OllamaMessageParser()
elif (
provide_name == "moonshot"
provide_name == "moonshot_ai"
): # AgentScopeGenAiProviderName.MOONSHOT.value
# Moonshot uses OpenAI-compatible API format
parser = OpenAIMessageParser()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class AgentScopeGenAiProviderName(str, Enum):

OLLAMA = "ollama"
DASHSCOPE = "dashscope"
MOONSHOT = "moonshot"
MOONSHOT = "moonshot_ai"


# Provider name mapping based on class names
Expand All @@ -79,6 +79,7 @@ class AgentScopeGenAiProviderName(str, Enum):
"anthropic": GenAIAttributes.GenAiProviderNameValues.ANTHROPIC.value,
"dashscope": AgentScopeGenAiProviderName.DASHSCOPE.value,
"ollama": AgentScopeGenAiProviderName.OLLAMA.value,
"moonshot": AgentScopeGenAiProviderName.MOONSHOT.value,
}

# Base URL to provider mapping for OpenAI-compatible APIs
Expand All @@ -89,6 +90,7 @@ class AgentScopeGenAiProviderName(str, Enum):
GenAIAttributes.GenAiProviderNameValues.DEEPSEEK.value,
),
("dashscope.aliyuncs.com", AgentScopeGenAiProviderName.DASHSCOPE.value),
("api.moonshot.cn", AgentScopeGenAiProviderName.MOONSHOT.value),
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
create_embedding_invocation,
create_llm_invocation,
entry_baggage_identity_attributes,
get_provider_name,
)
from opentelemetry.util.genai.extended_semconv.gen_ai_extended_attributes import (
GEN_AI_SESSION_ID,
Expand All @@ -47,6 +48,18 @@


class TestUtils:
def test_moonshot_provider_uses_upstream_value(self):
class MoonshotChatModel:
pass

assert get_provider_name(MoonshotChatModel()) == "moonshot_ai"

def test_moonshot_compatible_base_url_uses_upstream_value(self):
class DashScopeChatModel:
base_http_api_url = "https://api.moonshot.cn/v1"

assert get_provider_name(DashScopeChatModel()) == "moonshot_ai"

def test_convert_msg_with_tool_result(self):
"""Test conversion of AgentScope Msg with ToolResultBlock (End-to-End)"""
# Construct a Msg object simulating a tool execution result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Align streaming timing and response extraction with the current upstream
GenAI conventions.
- Split Agno 2.x tool-call loops into ReAct step spans with one LLM span per
provider request, and populate streaming agent token usage from child LLM
spans when Agno run events do not include metrics.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,37 @@ def _is_stream_close(exc: BaseException) -> bool:
return isinstance(exc, (GeneratorExit, StopIteration, StopAsyncIteration))


def _safe_get(value: Any, name: str) -> Any:
if isinstance(value, Mapping):
return value.get(name)
try:
return getattr(value, name, None)
except (AttributeError, KeyError):
return None


def _response_has_token(response: Any) -> bool:
return any(
(
_safe_get(response, "content"),
_safe_get(response, "reasoning_content"),
_safe_get(response, "redacted_reasoning_content"),
_safe_get(response, "tool_calls"),
)
)


def _mark_llm_stream_item(invocation: Any, response: Any) -> None:
now = timeit.default_timer()
invocation.stream = True
if invocation.monotonic_first_chunk_s is None:
invocation.monotonic_first_chunk_s = now
if invocation.monotonic_first_token_s is None and _response_has_token(
response
):
invocation.monotonic_first_token_s = now


@dataclass
class _AgnoRunState:
handler: ExtendedTelemetryHandler
Expand Down Expand Up @@ -698,8 +729,7 @@ def process_response_stream(
try:
stream = wrapped(*args, **kwargs)
for response in stream:
if invocation.monotonic_first_token_s is None:
invocation.monotonic_first_token_s = timeit.default_timer()
_mark_llm_stream_item(invocation, response)
responses.append(response)
yield response
self._finish_llm_call(
Expand Down Expand Up @@ -744,8 +774,7 @@ async def aprocess_response_stream(
if inspect.isawaitable(stream):
stream = await stream
async for response in stream:
if invocation.monotonic_first_token_s is None:
invocation.monotonic_first_token_s = timeit.default_timer()
_mark_llm_stream_item(invocation, response)
responses.append(response)
yield response
self._finish_llm_call(
Expand Down Expand Up @@ -886,8 +915,7 @@ def response_stream(
try:
stream = wrapped(*args, **kwargs)
for response in stream:
if invocation.monotonic_first_token_s is None:
invocation.monotonic_first_token_s = timeit.default_timer()
_mark_llm_stream_item(invocation, response)
responses.append(response)
yield response
if responses:
Expand Down Expand Up @@ -967,8 +995,7 @@ async def aresponse_stream(
if inspect.isawaitable(stream):
stream = await stream
async for response in stream:
if invocation.monotonic_first_token_s is None:
invocation.monotonic_first_token_s = timeit.default_timer()
_mark_llm_stream_item(invocation, response)
responses.append(response)
yield response
if responses:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Capture time to first chunk independently from time to first token, and emit
the stream request indicator only when AutoGen enables model streaming.
- Avoid enriching non-AutoGen spans that use overlapping GenAI operation names.

## Version 0.7.0 (2026-07-03)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,15 +464,20 @@ def _direct_model_create_stream_wrapper(wrapped, instance, args, kwargs): # typ
async def _generator(): # type: ignore[no-untyped-def]
handler = _get_handler()
invocation = _make_direct_model_invocation(instance, args, kwargs)
invocation.stream = True
handler.start_llm(invocation)
_mark_autogen_live_span(invocation)
try:
async for item in wrapped(*args, **kwargs):
now = timeit.default_timer()
if invocation.monotonic_first_chunk_s is None:
invocation.monotonic_first_chunk_s = now
if (
isinstance(item, str)
and bool(item)
and invocation.monotonic_first_token_s is None
):
invocation.monotonic_first_token_s = timeit.default_timer()
invocation.monotonic_first_token_s = now
elif type(item).__name__ == "CreateResult":
apply_create_result(invocation, item)
yield item
Expand Down Expand Up @@ -523,6 +528,11 @@ async def _generator(): # type: ignore[no-untyped-def]
agent_name=agent_name,
output_type=output_type,
)
invocation.stream = (
True
if _arg_value(args, kwargs, "model_client_stream") is True
else None
)
handler.start_llm(invocation)
_mark_autogen_live_span(invocation)
try:
Expand All @@ -531,11 +541,15 @@ async def _generator(): # type: ignore[no-untyped-def]
_suppress_direct_model_span,
True,
):
now = timeit.default_timer()
if invocation.monotonic_first_chunk_s is None:
invocation.monotonic_first_chunk_s = now
if (
type(item).__name__ == "ModelClientStreamingChunkEvent"
and bool(getattr(item, "content", None))
and invocation.monotonic_first_token_s is None
):
invocation.monotonic_first_token_s = timeit.default_timer()
invocation.monotonic_first_token_s = now
elif type(item).__name__ == "CreateResult":
apply_create_result(invocation, item)
yield item
Expand Down
Loading
Loading