chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import Callable
from functools import partial
from typing import Any
async def run_in_executor(executor: Any, func: Callable, *args, **kwargs) -> Any:
"""Run a function in an executor."""
return await asyncio.get_event_loop().run_in_executor(executor, partial(func, *args, **kwargs))
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
__all__ = ["get_entra_auth_token"]
@@ -0,0 +1,37 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from azure.core.credentials import TokenCredential
from azure.core.exceptions import ClientAuthenticationError
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidAuthError
logger: logging.Logger = logging.getLogger(__name__)
def get_entra_auth_token(credential: "TokenCredential", token_endpoint: str) -> str | None:
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint.
The token endpoint may be specified as an environment variable, via the .env
file or as an argument. If the token endpoint is not provided, the default is None.
Args:
credential: The credential to use to retrieve the authentication token.
token_endpoint: The token endpoint to use to retrieve the authentication token.
Returns:
The Azure token or None if the token could not be retrieved.
"""
if not token_endpoint:
raise ServiceInvalidAuthError(
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
)
try:
auth_token = credential.get_token(token_endpoint)
except ClientAuthenticationError:
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`.")
return None
return auth_token.token if auth_token else None
+23
View File
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING
from semantic_kernel.contents.chat_history import ChatHistory
if TYPE_CHECKING:
from semantic_kernel.contents.chat_message_content import ChatMessageContent
def store_results(chat_history: ChatHistory, results: list["ChatMessageContent"]) -> ChatHistory:
"""Stores specific results in the context and chat prompt.
Args:
chat_history(ChatHistory): The current chat history instance.
results(list["ChatMessageContent"]): Messages to be stored in the history.
Returns:
ChatHistory: Updated chat history containing the new messages.
"""
for message in results:
chat_history.add_message(message=message)
return chat_history
@@ -0,0 +1,153 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from typing import Any, TypeVar, cast
T = TypeVar("T", bound=type[Any] | Callable[..., Any])
DEFAULT_RC_NOTE = (
"Features marked with this status are nearing completion and are considered "
"stable for most purposes, but may still incur minor refinements or "
"optimizations before achieving full general availability."
)
"""
Example usage:
@experimental
class MyExperimentalClass:
'''A class that is still evolving rapidly.'''
pass
@stage(status="experimental")
class MyExperimentalClass:
'''A class that is still evolving rapidly.'''
pass
@experimental
def my_experimental_function():
'''A function that is still evolving rapidly.'''
pass
@release_candidate
class MyRCClass:
'''A class that is nearly final, but still in release-candidate stage.'''
pass
@release_candidate("1.23.1-rc1")
class MyRCClass:
'''A class that is nearly final, but still in release-candidate stage.'''
pass
"""
def _update_docstring(obj: T, note: str) -> None:
"""Append or set the docstring of the given object with the specified note."""
if obj.__doc__:
obj.__doc__ += f"\n\n{note}"
else:
obj.__doc__ = note
def stage(
status: str = "experimental",
version: str | None = None,
note: str | None = None,
) -> Callable[[T], T]:
"""A general-purpose decorator for marking a function or a class.
It updates the docstring and attaches 'stage_status' (and optionally
'stage_version') as metadata. A custom 'note' may be provided to
override the default appended text.
Args:
status: The development stage (e.g., 'experimental', 'release_candidate', etc.).
version: Optional version or release info (e.g., '1.21.0-rc4').
note: A custom note to append to the docstring. If omitted, a default
note is used to indicate the stage and possible changes.
Returns:
A decorator that updates the docstring and metadata of
the target function/class.
"""
def decorator(obj: T) -> T:
entity_type = "class" if isinstance(obj, type) else "function"
ver_text = f" (Version: {version})" if version else ""
default_note = f"Note: This {entity_type} is marked as '{status}'{ver_text} and may change in the future."
final_note = note if note else default_note
_update_docstring(obj, final_note)
setattr(obj, "stage_status", status)
if version:
setattr(obj, "stage_version", version)
return obj
return decorator
def experimental(obj: T) -> T:
"""Decorator specifically for 'experimental' features.
It uses the general 'stage' decorator but also attaches
'is_experimental = True'.
"""
decorated = stage(status="experimental")(obj)
setattr(decorated, "is_experimental", True)
return decorated
def release_candidate(
func: T | str | None = None,
*,
version: str | None = None,
doc_string: str | None = None,
) -> T:
"""Decorator that designates a function/class as being in a 'release candidate' state.
By default, applies a descriptive note indicating near-completion and possible minor refinements
before achieving general availability. You may override this with a custom 'doc_string' if needed.
Usage:
1) @release_candidate
2) @release_candidate()
3) @release_candidate("1.21.3-rc1")
4) @release_candidate(version="1.21.3-rc1")
5) @release_candidate(doc_string="Custom RC note...")
6) @release_candidate(version="1.21.3-rc1", doc_string="Custom RC note...")
Args:
func:
- In cases (1) or (2), this is the function/class being decorated.
- In cases (3) or (4), this may be a version string or None.
version:
The RC version string, if provided.
doc_string:
An optional custom note to append to the docstring, overriding
the default RC descriptive note.
Returns:
The decorated object, with an updated docstring and
'is_release_candidate = True'.
"""
from semantic_kernel import DEFAULT_RC_VERSION
def _apply(obj: T, ver: str, note: str | None) -> T:
ver_text = f" (Version: {ver})" if ver else ""
rc_note = note if note is not None else f"{DEFAULT_RC_NOTE}{ver_text}"
decorated = stage(status="release_candidate", version=ver, note=rc_note)(obj)
setattr(decorated, "is_release_candidate", True)
return decorated
if func is not None and callable(func):
ver = version or DEFAULT_RC_VERSION
return _apply(cast(T, func), ver, doc_string)
ver_str = func if isinstance(func, str) else version
def wrapper(obj: T) -> T:
return _apply(obj, ver_str or DEFAULT_RC_VERSION, doc_string)
return wrapper # type: ignore
@@ -0,0 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncGenerator, AsyncIterable, Sequence
from typing import TypeVar
_T = TypeVar("_T")
async def desync_list(sync_list: Sequence[_T]) -> AsyncIterable[_T]: # noqa: RUF029
"""De synchronize a list of synchronous objects."""
for x in sync_list:
yield x
async def empty_generator() -> AsyncGenerator[_T, None]:
"""An empty generator, can be used to return an empty generator."""
if False:
yield None
await asyncio.sleep(0)
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
def setup_logging():
"""Setup a detailed logging format."""
logging.basicConfig(
format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
+19
View File
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
import random
import string
def generate_random_ascii_name(length: int = 16) -> str:
"""Generate a series of random ASCII characters of the specified length.
As example, plugin/function names can contain upper/lowercase letters, and underscores
Args:
length (int): The length of the string to generate.
Returns:
A string of random ASCII characters of the specified length.
"""
letters = string.ascii_letters
return "".join(random.choices(letters, k=length)) # nosec
@@ -0,0 +1,253 @@
# Copyright (c) Microsoft. All rights reserved.
import functools
import json
from collections.abc import AsyncIterable, Awaitable, Callable
from functools import reduce
from typing import ParamSpec, cast
from opentelemetry.trace import Span, StatusCode, get_tracer
from semantic_kernel.agents.agent import Agent, AgentResponseItem
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.agent_diagnostics import gen_ai_attributes
from semantic_kernel.utils.telemetry.model_diagnostics.model_diagnostics_settings import ModelDiagnosticSettings
# Module to instrument GenAI agents using OpenTelemetry and OpenTelemetry Semantic Conventions.
# These are experimental features and may change in the future.
# To enable these features, set one of the following environment variables to true:
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE
# We are re-using the model diagnostic settings to control the instrumentation of agents
# because it makes sense to have a system wide setting for diagnostics. The name "model"
# is a legacy name because the concept of agent was not yet introduced when the settings were created.
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
P = ParamSpec("P")
# Creates a tracer from the global tracer provider
tracer = get_tracer(__name__)
OPERATION_NAME = "invoke_agent"
@experimental
def are_model_diagnostics_enabled() -> bool:
"""Check if model diagnostics are enabled.
Model diagnostics are enabled if either diagnostic is enabled or diagnostic with sensitive events is enabled.
"""
return (
MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics
or MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
)
@experimental
def are_sensitive_events_enabled() -> bool:
"""Check if sensitive events are enabled.
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
"""
return MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
@experimental
def trace_agent_get_response(
get_response_func: Callable[P, Awaitable[AgentResponseItem[ChatMessageContent]]],
) -> Callable[P, Awaitable[AgentResponseItem[ChatMessageContent]]]:
"""Decorator to trace agent invocation."""
@functools.wraps(get_response_func)
async def wrapper_decorator(
*args: P.args,
**kwargs: P.kwargs,
) -> AgentResponseItem[ChatMessageContent]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the responses
return await get_response_func(*args, **kwargs)
agent = cast(Agent, args[0])
messages = args[1] if len(args) > 1 else None
with _start_as_current_span(agent) as span:
try:
_set_agent_invocation_input(span, messages) # type: ignore
response = await get_response_func(*args, **kwargs)
_set_agent_invocation_output(span, [response.message])
return response
except Exception as e:
_set_agent_invocation_error(span, e)
raise
# Mark the wrapper decorator as an agent diagnostics decorator
wrapper_decorator.__agent_diagnostics__ = True # type: ignore
return wrapper_decorator
@experimental
def trace_agent_invocation(
invoke_func: Callable[P, AsyncIterable[AgentResponseItem[ChatMessageContent]]],
) -> Callable[P, AsyncIterable[AgentResponseItem[ChatMessageContent]]]:
"""Decorator to trace agent invocation."""
@functools.wraps(invoke_func)
async def wrapper_decorator(
*args: P.args,
**kwargs: P.kwargs,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the responses
async for response in invoke_func(*args, **kwargs):
yield response
return
agent = cast(Agent, args[0])
messages = args[1] if len(args) > 1 else None
with _start_as_current_span(agent) as current_span:
_set_agent_invocation_input(current_span, messages) # type: ignore
try:
responses: list[ChatMessageContent] = []
async for response in invoke_func(*args, **kwargs):
responses.append(response.message)
yield response
_set_agent_invocation_output(current_span, responses)
except Exception as e:
_set_agent_invocation_error(current_span, e)
raise
# Mark the wrapper decorator as an agent diagnostics decorator
wrapper_decorator.__agent_diagnostics__ = True # type: ignore
return wrapper_decorator
@experimental
def trace_agent_streaming_invocation(
invoke_func: Callable[P, AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]],
) -> Callable[P, AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]]:
"""Decorator to trace agent streaming invocation."""
@functools.wraps(invoke_func)
async def wrapper_decorator(
*args: P.args,
**kwargs: P.kwargs,
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the responses
async for chunk in invoke_func(*args, **kwargs):
yield chunk
return
agent = cast(Agent, args[0])
messages = args[1] if len(args) > 1 else None
with _start_as_current_span(agent) as current_span:
_set_agent_invocation_input(current_span, messages) # type: ignore
try:
chunks: list[StreamingChatMessageContent] = []
async for chunk in invoke_func(*args, **kwargs):
chunks.append(chunk.message)
yield chunk
# Concatenate the streaming chunks
if chunks:
response = reduce(lambda x, y: x + y, chunks)
_set_agent_invocation_output(current_span, [response])
else:
_set_agent_invocation_output(current_span, [])
except Exception as e:
_set_agent_invocation_error(current_span, e)
raise
# Mark the wrapper decorator as an agent diagnostics decorator
wrapper_decorator.__agent_diagnostics__ = True # type: ignore
return wrapper_decorator
def _start_as_current_span(agent: Agent):
"""Starts a span for the given agent.
Args:
agent (Agent): The agent for which to start the span.
Returns:
Span: The started span as a context manager.
"""
attributes = {
gen_ai_attributes.OPERATION: OPERATION_NAME,
gen_ai_attributes.AGENT_ID: agent.id,
gen_ai_attributes.AGENT_NAME: agent.name,
}
if agent.description:
attributes[gen_ai_attributes.AGENT_DESCRIPTION] = agent.description
if agent.kernel.plugins:
# This will only capture the tools that are available in the kernel at the time of agent creation.
# If the agent is invoked with another kernel instance, the tools in that kernel will not be captured.
from semantic_kernel.connectors.ai.function_calling_utils import (
kernel_function_metadata_to_function_call_format,
)
tool_definitions = [
kernel_function_metadata_to_function_call_format(metadata)
for metadata in agent.kernel.get_full_list_of_function_metadata()
]
attributes[gen_ai_attributes.AGENT_TOOL_DEFINITIONS] = json.dumps(tool_definitions)
return tracer.start_as_current_span(f"{OPERATION_NAME} {agent.name}", attributes=attributes)
def _set_agent_invocation_input(
current_span: Span,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None,
) -> None:
"""Set the agent input attributes in the span."""
if are_sensitive_events_enabled():
parsed_messages = _parse_agent_invocation_messages(messages)
current_span.set_attribute(
gen_ai_attributes.AGENT_INVOCATION_INPUT,
json.dumps([message.to_dict() for message in parsed_messages]),
)
def _set_agent_invocation_output(current_span: Span, response: list[ChatMessageContent]) -> None:
"""Set the agent output attributes in the span."""
if are_sensitive_events_enabled():
current_span.set_attribute(
gen_ai_attributes.AGENT_INVOCATION_OUTPUT,
json.dumps([message.to_dict() for message in response]),
)
def _set_agent_invocation_error(current_span: Span, error: Exception) -> None:
"""Set the agent error attributes in the span."""
current_span.set_attribute(gen_ai_attributes.ERROR_TYPE, type(error).__name__)
current_span.set_status(StatusCode.ERROR, repr(error))
def _parse_agent_invocation_messages(
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None,
) -> list[ChatMessageContent]:
"""Parse the agent invocation messages into a list of ChatMessageContent."""
if not messages:
return []
if isinstance(messages, str):
return [ChatMessageContent(role=AuthorRole.USER, content=messages)]
if isinstance(messages, ChatMessageContent):
return [messages]
if isinstance(messages, list):
return [
msg if isinstance(msg, ChatMessageContent) else ChatMessageContent(role=AuthorRole.USER, content=msg)
for msg in messages
]
return []
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
# Constants for tracing agent activities with semantic conventions.
# Ideally, we should use the attributes from the semcov package.
# However, many of the attributes are not yet available in the package,
# so we define them here for now.
# Activity tags
OPERATION = "gen_ai.operation.name"
AGENT_ID = "gen_ai.agent.id"
AGENT_NAME = "gen_ai.agent.name"
AGENT_DESCRIPTION = "gen_ai.agent.description"
AGENT_INVOCATION_INPUT = "gen_ai.input.messages"
AGENT_INVOCATION_OUTPUT = "gen_ai.output.messages"
AGENT_TOOL_DEFINITIONS = "gen_ai.tool.definitions"
ERROR_TYPE = "error.type"
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.utils.telemetry.model_diagnostics.decorators import (
trace_chat_completion,
trace_streaming_chat_completion,
trace_streaming_text_completion,
trace_text_completion,
)
__all__ = [
"trace_chat_completion",
"trace_streaming_chat_completion",
"trace_streaming_text_completion",
"trace_text_completion",
]
@@ -0,0 +1,453 @@
# Copyright (c) Microsoft. All rights reserved.
import functools
import json
import logging
from collections.abc import AsyncGenerator, Callable
from functools import reduce
from typing import TYPE_CHECKING, Any, ClassVar
from opentelemetry.trace import Span, StatusCode, get_tracer, use_span
from semantic_kernel.connectors.ai.completion_usage import CompletionUsage
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.model_diagnostics import gen_ai_attributes
from semantic_kernel.utils.telemetry.model_diagnostics.model_diagnostics_settings import ModelDiagnosticSettings
if TYPE_CHECKING:
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
# Module to instrument GenAI models using OpenTelemetry and OpenTelemetry Semantic Conventions.
# These are experimental features and may change in the future.
# To enable these features, set one of the following environment variables to true:
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
# Operation names
CHAT_COMPLETION_OPERATION = "chat"
TEXT_COMPLETION_OPERATION = "text_completions"
# We're recording multiple events for the chat history, some of them are emitted within (hundreds of)
# nanoseconds of each other. The default timestamp resolution is not high enough to guarantee unique
# timestamps for each message. Also Azure Monitor truncates resolution to microseconds and some other
# backends truncate to milliseconds.
#
# But we need to give users a way to restore chat message order, so we're incrementing the timestamp
# by 1 microsecond for each message.
#
# This is a workaround, we'll find a generic and better solution - see
# https://github.com/open-telemetry/semantic-conventions/issues/1701
class ChatHistoryMessageTimestampFilter(logging.Filter):
"""A filter to increment the timestamp of INFO logs by 1 microsecond."""
INDEX_KEY: ClassVar[str] = "CHAT_MESSAGE_INDEX"
def filter(self, record: logging.LogRecord) -> bool:
"""Increment the timestamp of INFO logs by 1 microsecond."""
if hasattr(record, self.INDEX_KEY):
idx = getattr(record, self.INDEX_KEY)
record.created += idx * 1e-6
return True
# Creates a tracer from the global tracer provider
tracer = get_tracer(__name__)
logger = logging.getLogger(__name__)
logger.addFilter(ChatHistoryMessageTimestampFilter())
@experimental
def are_model_diagnostics_enabled() -> bool:
"""Check if model diagnostics are enabled.
Model diagnostics are enabled if either diagnostic is enabled or diagnostic with sensitive events is enabled.
"""
return (
MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics
or MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
)
@experimental
def are_sensitive_events_enabled() -> bool:
"""Check if sensitive events are enabled.
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
"""
return MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
@experimental
def trace_chat_completion(model_provider: str) -> Callable:
"""Decorator to trace chat completion activities.
Args:
model_provider (str): The model provider should describe a family of
GenAI models with specific model identified by ai_model_id. For example,
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
Sometimes the model provider is unknown at runtime, in which case it can be
set to the most specific known provider. For example, while using local models
hosted by Ollama, the model provider could be set to "ollama".
"""
def inner_trace_chat_completion(completion_func: Callable) -> Callable:
@functools.wraps(completion_func)
async def wrapper_decorator(*args: Any, **kwargs: Any) -> list[ChatMessageContent]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the completion
return await completion_func(*args, **kwargs)
completion_service: "ChatCompletionClientBase" = args[0]
chat_history: ChatHistory = kwargs.get("chat_history") or args[1] # type: ignore
settings: "PromptExecutionSettings" = kwargs.get("settings") or args[2] # type: ignore
with use_span(
_get_completion_span(
CHAT_COMPLETION_OPERATION,
completion_service.ai_model_id,
model_provider,
completion_service.service_url(),
settings,
),
end_on_exit=True,
) as current_span:
_set_completion_input(model_provider, chat_history)
try:
completions: list[ChatMessageContent] = await completion_func(*args, **kwargs)
_set_completion_response(current_span, completions, model_provider)
return completions
except Exception as exception:
_set_completion_error(current_span, exception)
raise
# Mark the wrapper decorator as a chat completion decorator
wrapper_decorator.__model_diagnostics_chat_completion__ = True # type: ignore
return wrapper_decorator
return inner_trace_chat_completion
@experimental
def trace_streaming_chat_completion(model_provider: str) -> Callable:
"""Decorator to trace streaming chat completion activities.
Args:
model_provider (str): The model provider should describe a family of
GenAI models with specific model identified by ai_model_id. For example,
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
Sometimes the model provider is unknown at runtime, in which case it can be
set to the most specific known provider. For example, while using local models
hosted by Ollama, the model provider could be set to "ollama".
"""
def inner_trace_streaming_chat_completion(completion_func: Callable) -> Callable:
@functools.wraps(completion_func)
async def wrapper_decorator(
*args: Any, **kwargs: Any
) -> AsyncGenerator[list["StreamingChatMessageContent"], Any]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the completion
async for streaming_chat_message_contents in completion_func(*args, **kwargs):
yield streaming_chat_message_contents
return
completion_service: "ChatCompletionClientBase" = args[0]
chat_history: ChatHistory = kwargs.get("chat_history") or args[1] # type: ignore
settings: "PromptExecutionSettings" = kwargs.get("settings") or args[2] # type: ignore
all_messages: dict[int, list[StreamingChatMessageContent]] = {}
with use_span(
_get_completion_span(
CHAT_COMPLETION_OPERATION,
completion_service.ai_model_id,
model_provider,
completion_service.service_url(),
settings,
),
end_on_exit=True,
) as current_span:
_set_completion_input(model_provider, chat_history)
try:
async for streaming_chat_message_contents in completion_func(*args, **kwargs):
for streaming_chat_message_content in streaming_chat_message_contents:
choice_index = streaming_chat_message_content.choice_index
if choice_index not in all_messages:
all_messages[choice_index] = []
all_messages[choice_index].append(streaming_chat_message_content)
yield streaming_chat_message_contents
all_messages_flattened = [
reduce(lambda x, y: x + y, messages) for messages in all_messages.values()
]
_set_completion_response(current_span, all_messages_flattened, model_provider)
except Exception as exception:
_set_completion_error(current_span, exception)
raise
# Mark the wrapper decorator as a streaming chat completion decorator
wrapper_decorator.__model_diagnostics_streaming_chat_completion__ = True # type: ignore
return wrapper_decorator
return inner_trace_streaming_chat_completion
@experimental
def trace_text_completion(model_provider: str) -> Callable:
"""Decorator to trace text completion activities.
Args:
model_provider (str): The model provider should describe a family of
GenAI models with specific model identified by ai_model_id. For example,
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
Sometimes the model provider is unknown at runtime, in which case it can be
set to the most specific known provider. For example, while using local models
hosted by Ollama, the model provider could be set to "ollama".
"""
def inner_trace_text_completion(completion_func: Callable) -> Callable:
@functools.wraps(completion_func)
async def wrapper_decorator(*args: Any, **kwargs: Any) -> list[TextContent]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the completion
return await completion_func(*args, **kwargs)
completion_service: "TextCompletionClientBase" = args[0]
prompt: str = kwargs.get("prompt") if kwargs.get("prompt") is not None else args[1] # type: ignore
settings: "PromptExecutionSettings" = kwargs["settings"] if kwargs.get("settings") is not None else args[2]
with use_span(
_get_completion_span(
TEXT_COMPLETION_OPERATION,
completion_service.ai_model_id,
model_provider,
completion_service.service_url(),
settings,
),
end_on_exit=True,
) as current_span:
_set_completion_input(model_provider, prompt)
try:
completions: list[TextContent] = await completion_func(*args, **kwargs)
_set_completion_response(current_span, completions, model_provider)
return completions
except Exception as exception:
_set_completion_error(current_span, exception)
raise
# Mark the wrapper decorator as a text completion decorator
wrapper_decorator.__model_diagnostics_text_completion__ = True # type: ignore
return wrapper_decorator
return inner_trace_text_completion
@experimental
def trace_streaming_text_completion(model_provider: str) -> Callable:
"""Decorator to trace streaming text completion activities.
Args:
model_provider (str): The model provider should describe a family of
GenAI models with specific model identified by ai_model_id. For example,
model_provider could be "openai" and ai_model_id could be "gpt-3.5-turbo".
Sometimes the model provider is unknown at runtime, in which case it can be
set to the most specific known provider. For example, while using local models
hosted by Ollama, the model provider could be set to "ollama".
"""
def inner_trace_streaming_text_completion(completion_func: Callable) -> Callable:
@functools.wraps(completion_func)
async def wrapper_decorator(*args: Any, **kwargs: Any) -> AsyncGenerator[list["StreamingTextContent"], Any]:
if not are_model_diagnostics_enabled():
# If model diagnostics are not enabled, just return the completion
async for streaming_text_contents in completion_func(*args, **kwargs):
yield streaming_text_contents
return
completion_service: "TextCompletionClientBase" = args[0]
prompt: str = kwargs.get("prompt") if kwargs.get("prompt") is not None else args[1] # type: ignore
settings: "PromptExecutionSettings" = kwargs["settings"] if kwargs.get("settings") is not None else args[2]
all_text_contents: dict[int, list["StreamingTextContent"]] = {}
with use_span(
_get_completion_span(
TEXT_COMPLETION_OPERATION,
completion_service.ai_model_id,
model_provider,
completion_service.service_url(),
settings,
),
end_on_exit=True,
) as current_span:
_set_completion_input(model_provider, prompt)
try:
async for streaming_text_contents in completion_func(*args, **kwargs):
for streaming_text_content in streaming_text_contents:
choice_index = streaming_text_content.choice_index
if choice_index not in all_text_contents:
all_text_contents[choice_index] = []
all_text_contents[choice_index].append(streaming_text_content)
yield streaming_text_contents
all_text_contents_flattened = [
reduce(lambda x, y: x + y, messages) for messages in all_text_contents.values()
]
_set_completion_response(current_span, all_text_contents_flattened, model_provider)
except Exception as exception:
_set_completion_error(current_span, exception)
raise
# Mark the wrapper decorator as a streaming text completion decorator
wrapper_decorator.__model_diagnostics_streaming_text_completion__ = True # type: ignore
return wrapper_decorator
return inner_trace_streaming_text_completion
def _get_completion_span(
operation_name: str,
model_name: str,
model_provider: str,
service_url: str | None,
execution_settings: "PromptExecutionSettings | None",
) -> Span:
"""Start a text or chat completion span for a given model.
Note that `start_span` doesn't make the span the current span.
Use `use_span` to make it the current span as a context manager.
"""
span = tracer.start_span(f"{operation_name} {model_name}")
# Set attributes on the span
span.set_attributes({
gen_ai_attributes.OPERATION: operation_name,
gen_ai_attributes.SYSTEM: model_provider,
gen_ai_attributes.MODEL: model_name,
})
if service_url:
span.set_attribute(gen_ai_attributes.ADDRESS, service_url)
# TODO(@glahaye): we'll need to have a way to get these attributes from model
# providers other than OpenAI (for example if the attributes are named differently)
if execution_settings:
attribute_name_map = {
"seed": gen_ai_attributes.SEED,
"encoding_formats": gen_ai_attributes.ENCODING_FORMATS,
"frequency_penalty": gen_ai_attributes.FREQUENCY_PENALTY,
"max_tokens": gen_ai_attributes.MAX_TOKENS,
"stop_sequences": gen_ai_attributes.STOP_SEQUENCES,
"temperature": gen_ai_attributes.TEMPERATURE,
"top_k": gen_ai_attributes.TOP_K,
"top_p": gen_ai_attributes.TOP_P,
}
for attribute_name, attribute_key in attribute_name_map.items():
attribute = execution_settings.extension_data.get(attribute_name)
if attribute:
span.set_attribute(attribute_key, attribute)
return span
def _set_completion_input(
model_provider: str,
prompt: str | ChatHistory,
) -> None:
"""Set the input for a text or chat completion.
The logs will be associated to the current span.
"""
if are_sensitive_events_enabled():
if isinstance(prompt, ChatHistory):
for idx, message in enumerate(prompt.messages):
event_name = gen_ai_attributes.ROLE_EVENT_MAP.get(message.role)
if event_name:
logger.info(
json.dumps(message.to_dict()),
extra={
gen_ai_attributes.EVENT_NAME: event_name,
gen_ai_attributes.SYSTEM: model_provider,
ChatHistoryMessageTimestampFilter.INDEX_KEY: idx,
},
)
else:
logger.info(
prompt,
extra={
gen_ai_attributes.EVENT_NAME: gen_ai_attributes.PROMPT,
gen_ai_attributes.SYSTEM: model_provider,
},
)
def _set_completion_response(
current_span: Span,
completions: list[ChatMessageContent]
| list[TextContent]
| list[StreamingChatMessageContent]
| list[StreamingTextContent],
model_provider: str,
) -> None:
"""Set the a text or chat completion response for a given span."""
first_completion = completions[0]
# Set the response ID
response_id = first_completion.metadata.get("id")
if response_id:
current_span.set_attribute(gen_ai_attributes.RESPONSE_ID, response_id)
# Set the finish reason
finish_reasons = [
str(completion.finish_reason) for completion in completions if isinstance(completion, ChatMessageContent)
]
if finish_reasons:
current_span.set_attribute(gen_ai_attributes.FINISH_REASON, ",".join(finish_reasons))
# Set usage attributes
usage = first_completion.metadata.get("usage", None)
if isinstance(usage, CompletionUsage):
if usage.prompt_tokens:
current_span.set_attribute(gen_ai_attributes.INPUT_TOKENS, usage.prompt_tokens)
if usage.completion_tokens:
current_span.set_attribute(gen_ai_attributes.OUTPUT_TOKENS, usage.completion_tokens)
# Set the completion event
if are_sensitive_events_enabled():
for completion in completions:
full_response: dict[str, Any] = {
"message": completion.to_dict(),
}
if isinstance(completion, ChatMessageContent):
full_response["finish_reason"] = completion.finish_reason
if isinstance(completion, StreamingContentMixin):
full_response["index"] = completion.choice_index
logger.info(
json.dumps(full_response),
extra={
gen_ai_attributes.EVENT_NAME: gen_ai_attributes.CHOICE,
gen_ai_attributes.SYSTEM: model_provider,
},
)
def _set_completion_error(span: Span, error: Exception) -> None:
"""Set an error for a text or chat completion ."""
span.set_attribute(gen_ai_attributes.ERROR_TYPE, str(type(error)))
span.set_status(StatusCode.ERROR, repr(error))
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING, Any
from opentelemetry import trace
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.model_diagnostics.gen_ai_attributes import (
OPERATION,
TOOL_CALL_ID,
TOOL_DESCRIPTION,
TOOL_NAME,
)
from semantic_kernel.utils.telemetry.model_diagnostics.model_diagnostics_settings import ModelDiagnosticSettings
if TYPE_CHECKING:
from semantic_kernel.functions.kernel_function import KernelFunction
# The operation name is defined by OTeL GenAI semantic conventions:
# https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#execute-tool-span
OPERATION_NAME = "execute_tool"
# To enable these features, set one of the following environment variables to true:
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS
# SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE
MODEL_DIAGNOSTICS_SETTINGS = ModelDiagnosticSettings()
@experimental
def are_sensitive_events_enabled() -> bool:
"""Check if sensitive events are enabled.
Sensitive events are enabled if the diagnostic with sensitive events is enabled.
"""
return MODEL_DIAGNOSTICS_SETTINGS.enable_otel_diagnostics_sensitive
def start_as_current_span(
tracer: trace.Tracer,
function: "KernelFunction",
metadata: dict[str, Any] | None = None,
):
"""Starts a span for the given function using the provided tracer.
Args:
tracer (trace.Tracer): The OpenTelemetry tracer to use.
function (KernelFunction): The function for which to start the span.
metadata (dict[str, Any] | None): Optional metadata to include in the span attributes.
Returns:
trace.Span: The started span as a context manager.
"""
attributes = {
OPERATION: OPERATION_NAME,
TOOL_NAME: function.fully_qualified_name,
}
tool_call_id = metadata.get("id", None) if metadata else None
if tool_call_id:
attributes[TOOL_CALL_ID] = tool_call_id
if function.description:
attributes[TOOL_DESCRIPTION] = function.description
return tracer.start_as_current_span(f"{OPERATION_NAME} {function.fully_qualified_name}", attributes=attributes)
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.contents.utils.author_role import AuthorRole
# Constants for tracing activities with semantic conventions.
# Ideally, we should use the attributes from the semcov package.
# However, many of the attributes are not yet available in the package,
# so we define them here for now.
# Activity tags
OPERATION = "gen_ai.operation.name"
SYSTEM = "gen_ai.system"
ERROR_TYPE = "error.type"
MODEL = "gen_ai.request.model"
SEED = "gen_ai.request.seed"
PORT = "server.port"
ENCODING_FORMATS = "gen_ai.request.encoding_formats"
FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
MAX_TOKENS = "gen_ai.request.max_tokens"
STOP_SEQUENCES = "gen_ai.request.stop_sequences"
TEMPERATURE = "gen_ai.request.temperature"
TOP_K = "gen_ai.request.top_k"
TOP_P = "gen_ai.request.top_p"
FINISH_REASON = "gen_ai.response.finish_reason"
RESPONSE_ID = "gen_ai.response.id"
INPUT_TOKENS = "gen_ai.usage.input_tokens"
OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
TOOL_CALL_ID = "gen_ai.tool.call.id"
TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
TOOL_CALL_RESULT = "gen_ai.tool.call.result"
TOOL_DESCRIPTION = "gen_ai.tool.description"
TOOL_NAME = "gen_ai.tool.name"
ADDRESS = "server.address"
# Activity events
EVENT_NAME = "event.name"
SYSTEM_MESSAGE = "gen_ai.system.message"
USER_MESSAGE = "gen_ai.user.message"
ASSISTANT_MESSAGE = "gen_ai.assistant.message"
TOOL_MESSAGE = "gen_ai.tool.message"
CHOICE = "gen_ai.choice"
PROMPT = "gen_ai.prompt"
# Kernel specific attributes
AVAILABLE_FUNCTIONS = "sk.available_functions"
ROLE_EVENT_MAP = {
AuthorRole.SYSTEM: SYSTEM_MESSAGE,
AuthorRole.USER: USER_MESSAGE,
AuthorRole.ASSISTANT: ASSISTANT_MESSAGE,
AuthorRole.TOOL: TOOL_MESSAGE,
}
@@ -0,0 +1,31 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import ClassVar
from semantic_kernel.kernel_pydantic import KernelBaseSettings
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ModelDiagnosticSettings(KernelBaseSettings):
"""Settings for model diagnostics.
The settings are first loaded from environment variables with
the prefix 'SEMANTICKERNEL_EXPERIMENTAL_GENAI_'.
If the environment variables are not found, the settings can
be loaded from a .env file with the encoding 'utf-8'.
If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the
settings are missing.
Required settings for prefix 'SEMANTICKERNEL_EXPERIMENTAL_GENAI_' are:
- enable_otel_diagnostics: bool - Enable OpenTelemetry diagnostics. Default is False.
(Env var SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS)
- enable_otel_diagnostics_sensitive: bool - Enable OpenTelemetry sensitive events. Default is False.
(Env var SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE)
"""
env_prefix: ClassVar[str] = "SEMANTICKERNEL_EXPERIMENTAL_GENAI_"
enable_otel_diagnostics: bool = False
enable_otel_diagnostics_sensitive: bool = False
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from importlib.metadata import PackageNotFoundError, version
from typing import Any
from semantic_kernel.const import USER_AGENT
# Note that if this environment variable does not exist, telemetry is enabled.
TELEMETRY_DISABLED_ENV_VAR = "AZURE_TELEMETRY_DISABLED"
IS_TELEMETRY_ENABLED = os.environ.get(TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
HTTP_USER_AGENT = "semantic-kernel-python"
try:
version_info = version("semantic-kernel")
except PackageNotFoundError:
version_info = "dev"
APP_INFO = (
{
"semantic-kernel-version": f"python/{version_info}",
}
if IS_TELEMETRY_ENABLED
else None
)
SEMANTIC_KERNEL_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
def prepend_semantic_kernel_to_user_agent(headers: dict[str, Any]):
"""Prepend "semantic-kernel" to the User-Agent in the headers.
Args:
headers: The existing headers dictionary.
Returns:
The modified headers dictionary with "semantic-kernel-python/{version}" prepended to the User-Agent.
"""
headers[USER_AGENT] = (
f"{SEMANTIC_KERNEL_USER_AGENT} {headers[USER_AGENT]}" if USER_AGENT in headers else SEMANTIC_KERNEL_USER_AGENT
)
return headers
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
AGENT_NAME_REGEX = r"^[0-9A-Za-z_-]+$"
PLUGIN_NAME_REGEX = r"^[0-9A-Za-z_]+$"
FUNCTION_NAME_REGEX = r"^[0-9A-Za-z_-]+$"
FULLY_QUALIFIED_FUNCTION_NAME = r"^(?P<plugin>[0-9A-Za-z_]+)[.](?P<function>[0-9A-Za-z_-]+)$"
FUNCTION_PARAM_NAME_REGEX = r"^[0-9A-Za-z_-]+$"